Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cloudflare/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class ArtifactLinks extends DurableObject<unknown> {
catalog(input: Parameters<LibraryOwnership["catalog"]>[0]) { return this.ownership.catalog(input); }
fetch(request: Request) { return this.subscriptions.accept(request); }
changed(target: ArtifactTarget) { this.subscriptions.changed(this.ownership.owner(target), target.workspace); }
nativeAppChanged(selection: { libraryKey: string; workspace: string }) { this.subscriptions.changed(selection.libraryKey, selection.workspace); }
webSocketMessage(socket: WebSocket) { socket.close(1008, "Receive-only subscription"); }
webSocketClose(socket: WebSocket, code: number, reason: string) { socket.close(code, reason); }
webSocketError(socket: WebSocket) { socket.close(1011, "Subscription disconnected"); }
Expand Down
9 changes: 9 additions & 0 deletions cloudflare/managed-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export class ManagedArtifactService {
default: throw new Error("Unknown app tool");
}
const payload = result as Record<string, unknown>;
if (["app_write", "app_restore", "app_reconcile", "app_move"].includes(tool) || (tool === "app_secrets" && args.secrets)) {
await this.links.nativeAppChanged({ libraryKey: this.libraryKey, workspace: this.workspace });
if (tool === "app_move") await this.links.nativeAppChanged({ libraryKey: args.library === "team" ? "team" : this.privateKey, workspace: this.workspace });
}
const failed = payload.ok === false || (payload.deployment as { ok?: boolean } | undefined)?.ok === false;
return { ...text(result), structuredContent: payload, isError: failed };
}
Expand Down Expand Up @@ -169,7 +173,12 @@ export class ManagedArtifactService {
: await this.env.LIBRARIES.getByName(target.libraryKey).draftRevision({ workspace: item.workspace, name: item.name });
}
}
const workers = this.env.NATIVE_APPS
? await this.nativeController().list({ owner: this.libraryKey, workspace: all ? undefined : this.workspace, offset })
: null;
hasMore ||= workers?.next_offset != null;
return { capabilities: { scripts: true, links: true, moves: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true }, workspace: this.workspace,
...(workers ? { workerApps: workers.apps.map(app => ({ ...app, kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) })), nativeAppProviders: workers.providers } : {}),
artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null };
}
}
24 changes: 24 additions & 0 deletions cloudflare/native-gallery-test-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export { ArtifactLibrary } from "./library";
export { ScriptLibrary } from "./scripts";
export { ArtifactLinks } from "./links";
export { NativeApps } from "./native-worker/controller";
import { ManagedArtifactService } from "./managed-service";
import type { Env } from "./worker";

export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const owner = url.searchParams.get("owner") ?? "alice";
const workspace = url.searchParams.get("workspace") ?? "default";
const service = new ManagedArtifactService(env, workspace, owner, "alice", url.origin, url.origin, { user: { subject: owner, authority: "test" }, env });
try {
if (url.pathname === "/subscribe") return service.subscribeGallery(url.searchParams.get("all") === "1");
if (url.pathname === "/gallery") return Response.json(await service.gallery(url.searchParams.get("all") === "1", Number(url.searchParams.get("offset") ?? 0)));
if (url.pathname === "/seed-artifact") return Response.json(await env.LIBRARIES.getByName(owner).writeDraft({ workspace, name: "report", source: "source" }));
const input = await request.json() as { name: string; arguments: Record<string, unknown> };
return Response.json(await service.callTool(input.name, input.arguments));
} catch (error) {
return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
}
},
};
86 changes: 86 additions & 0 deletions cloudflare/native-gallery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test } from "bun:test";
import { Miniflare } from "miniflare";
import type { GalleryData } from "../src/gallery/types";
import { openGallerySubscription } from "../src/test/gallery-subscription";

test("gallery lists Worker drafts across workspaces with owner isolation, pagination and live updates", async () => {
const build = await Bun.build({
entrypoints: [new URL("./native-gallery-test-worker.ts", import.meta.url).pathname], target: "browser", format: "esm", external: ["cloudflare:workers", "node:*", "fs", "fs/promises"],
plugins: [{ name: "draft-only-compiler", setup(build) {
// Persist real controller drafts without contacting a deployment provider.
build.onLoad({ filter: /\/cloudflare\/compiler\.ts$/ }, () => ({ loader: "ts", contents: `
export function compileNativeWorker() { return { ok: false, diagnostics: [{ severity: "error", message: "Fixture draft" }] }; }
function unexpected() { throw new Error("Gallery reads must not compile or execute projects"); }
export { unexpected as compileArtifactSource, unexpected as compileArtifactServerSource, unexpected as compileScriptSource,
unexpected as typecheckArtifactSource, unexpected as typecheckArtifactServerSource };
` }));
} }],
});
if (!build.success) throw new Error(build.logs.join("\n"));
const bindings = { LIBRARIES: "ArtifactLibrary", SCRIPTS: "ScriptLibrary", LINKS: "ArtifactLinks", NATIVE_APPS: "NativeApps" };
const runtime = new Miniflare({ cf: false, port: 0, workers: [{ config: {
name: "native-gallery-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"],
manifest: { mainModule: "worker.js", modulesRoot: import.meta.dir, modules: { "worker.js": { type: "esm", contents: await build.outputs[0]!.text() } } },
env: {
...Object.fromEntries(Object.entries(bindings).map(([name, exportName]) => [name, { type: "durable-object", worker: "native-gallery-test", exportName }])),
NATIVE_CF_ACCOUNT_ID: { type: "text", value: "a".repeat(32) }, NATIVE_CF_API_TOKEN: { type: "text", value: "fixture-provider-secret" },
},
exports: Object.fromEntries(Object.values(bindings).map(name => [name, { type: "durable-object", storage: "sqlite" }])),
}, dev: {} }] } as ConstructorParameters<typeof Miniflare>[0]);
const origin = (await runtime.ready).origin;
async function request(path: string, input?: unknown) {
const response = await fetch(`${origin}${path}`, input === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) });
const result = await response.json() as any;
if (!response.ok) throw new Error(result.error);
return result;
}
const gallery = (params = ""): Promise<GalleryData> => request(`/gallery?${params}`);
const write = (name: string, params = "") => request(`/tools?${params}`, { name: "app_write", arguments: {
name, source: "private draft source", manifest: { main: "worker.ts", compatibility_date: "2026-09-06" }, expected_revision: null,
} });
const subscriptions = await Promise.all([
openGallerySubscription(`${origin}/subscribe?all=1`),
openGallerySubscription(`${origin}/subscribe?workspace=other`),
openGallerySubscription(`${origin}/subscribe?owner=bob&all=1`),
openGallerySubscription(`${origin}/subscribe?owner=team&all=1`),
]);
const [alice, otherWorkspace, bob, team] = subscriptions;
try {
await request("/seed-artifact", {});
await write("example");
expect(await alice!.changes()).toEqual(["changed"]);
expect(await otherWorkspace!.changes()).toEqual([]);
expect(await bob!.changes()).toEqual([]);
expect(await team!.changes()).toEqual([]);
await write("example", "workspace=other");
await write("bob-only", "owner=bob");
const all = await gallery("all=1");
expect(all.artifacts.map(item => item.name)).toEqual(["report"]);
expect(all.workerApps?.map(item => [item.name, item.workspace, item.kind])).toEqual([["example", "default", "worker"], ["example", "other", "worker"]]);
expect(new Set(all.workerApps?.map(item => item.key)).size).toBe(2);
expect(all.nativeAppProviders).toEqual(["cloudflare"]);
expect(JSON.stringify(all)).not.toContain("private draft source");
expect(JSON.stringify(all)).not.toContain("fixture-provider-secret");
expect((await gallery()).workerApps?.map(item => item.workspace)).toEqual(["default"]);
expect((await gallery("owner=bob&all=1")).workerApps?.map(item => item.name)).toEqual(["bob-only"]);
for (let index = 0; index < 100; index++) await write(`worker-${String(index).padStart(3, "0")}`, "workspace=other");
const first = await gallery("all=1"), second = await gallery("all=1&offset=100");
expect(first.workerApps).toHaveLength(100);
expect(first.nextOffset).toBe(100);
expect(second.workerApps).toHaveLength(2);
expect(second.nextOffset).toBeNull();
expect(new Set([...first.workerApps!, ...second.workerApps!].map(item => item.key)).size).toBe(102);
// Moving an app invalidates both libraries and removes the old owner's row.
await Promise.all(subscriptions.map(subscription => subscription.changes()));
await request("/tools", { name: "app_move", arguments: { name: "example", library: "team" } });
expect(await alice!.changes()).toEqual(["changed"]);
expect(await team!.changes()).toEqual(["changed"]);
expect(await bob!.changes()).toEqual([]);
expect(await otherWorkspace!.changes()).toEqual([]);
expect((await gallery()).workerApps).toEqual([]);
expect((await gallery("owner=team&all=1")).workerApps?.map(item => item.name)).toEqual(["example"]);
} finally {
subscriptions.forEach(subscription => subscription.close());
await runtime.dispose();
}
}, 30000);
6 changes: 4 additions & 2 deletions cloudflare/native-worker/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ export class NativeApps extends DurableObject<NativeEnvironment> {
return { name: row.name, workspace: row.workspace, provider: row.provider, desired_revision: row.desired, active_revision: row.active,
revision_token: createHash("sha256").update(row.draft).digest("hex"), status: row.status, stage: row.stage, error: row.error, updated_at: row.updated_at };
}
list(input: { owner: string; workspace: string; offset?: number }) {
list(input: { owner: string; workspace?: string; offset?: number }) {
const offset = input.offset ?? 0;
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid app pagination");
const rows = this.sql.exec<Row>("select * from native_apps where owner=? and workspace=? order by name limit 100 offset ?", input.owner, input.workspace, offset).toArray();
const rows = input.workspace === undefined
? this.sql.exec<Row>("select * from native_apps where owner=? order by name,workspace limit 100 offset ?", input.owner, offset).toArray()
: this.sql.exec<Row>("select * from native_apps where owner=? and workspace=? order by name limit 100 offset ?", input.owner, input.workspace, offset).toArray();
return { apps: rows.map(row => this.summary(row)), next_offset: rows.length === 100 ? offset + 100 : null, providers: nativeProviders(this.env) };
}
read(input: Selection & { revision_id?: string }) {
Expand Down
4 changes: 2 additions & 2 deletions docs/native-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ with [app backends](app-backends.md); for an endpoint or scheduled job with SQLi
start with [scripts](scripts.md).

Your administrator must configure a [deployment provider](#providers) before you
can deploy native apps. Then use **Worker apps** in the gallery, or ask your agent
can deploy native apps. Then choose **New Worker app** from the **+** menu beside Artifacts, or ask your agent
to read `app_guide`. Native app URLs are private; they do not currently support
public links.

Native apps deploy ordinary Cloudflare Worker modules with their default handler and named Durable Object exports. They use native KV, R2, D1, queue and DO bindings rather than the script SDK's `env.sql` wrapper. The gallery's **Worker apps** button and hosted MCP `app_*` tools use the same authenticated deployment controller.
Native apps deploy ordinary Cloudflare Worker modules with their default handler and named Durable Object exports. They use native KV, R2, D1, queue and DO bindings rather than the script SDK's `env.sql` wrapper. Worker apps appear alongside artifacts and scripts in the sidebar, with the same folders and search. Select one to edit its source, resources, triggers, deployments, and settings in the detail pane. The gallery and hosted MCP `app_*` tools use the same authenticated deployment controller.

An app belongs to a private or team library and a workspace. Its UUID permanently owns provider resources; source revisions, export names, logical library moves and binding names do not change those identities. App URLs are private:

Expand Down
Loading
Loading