Skip to content

Commit 746ce99

Browse files
committed
examples/artifacts: add workspace publish demo
Add an example Worker that exposes POST /create. The endpoint gets a Workspace from a minimal Durable Object, uses the worker backend shell to clone and rewrite examples/worker, creates a session-scoped Artifact repository through the built-in shell command, pushes the generated project, and returns a short-lived read clone link.
1 parent b3081b0 commit 746ce99

7 files changed

Lines changed: 14148 additions & 0 deletions

File tree

examples/artifacts/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist/
2+
node_modules/
3+
.wrangler/
4+
.dev.vars*
5+
!.dev.vars.example

examples/artifacts/README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Workspace Artifacts example
2+
3+
This example generates a Worker project in a Workspace, publishes it to Cloudflare Artifacts, and returns a clone-ready URL.
4+
5+
Run it with Wrangler:
6+
7+
```sh
8+
npm run dev --workspace @example/workspace-artifacts
9+
```
10+
11+
Or deploy it and test against the remote runtime:
12+
13+
```sh
14+
npx wrangler deploy --config examples/artifacts/wrangler.jsonc
15+
```
16+
17+
Create a generated Worker by posting a Worker-safe name:
18+
19+
```sh
20+
curl -X POST http://localhost:8787/create \
21+
-H 'content-type: application/json' \
22+
-d '{"name":"my-generated-worker"}'
23+
```
24+
25+
Against a deployed Worker:
26+
27+
```sh
28+
curl -X POST https://<worker-subdomain>.workers.dev/create \
29+
-H 'content-type: application/json' \
30+
-d '{"name":"my-generated-worker"}'
31+
```
32+
33+
The Worker endpoint owns the orchestration. The durable object stays minimal: it owns the `Workspace`, exposes `getWorkspace()`, and bridges the host Artifacts binding into the worker-backend shell's `artifacts` command.
34+
35+
`POST /create` does the following through `ws.shell.exec(...)`:
36+
37+
1. clones `https://github.com/cloudflare/workspace` into `/workspace/<name>-source`;
38+
2. copies `/workspace/<name>-source/examples/worker` to `/workspace/<name>`;
39+
3. rewrites the copied Worker name with `sed`;
40+
4. initializes and commits the generated project with the shell `git` command;
41+
5. replaces any prior session-scoped Artifact repo with the shell `artifacts` command;
42+
6. pushes `HEAD:main` to the Artifact remote;
43+
7. creates a short-lived read token with the shell `artifacts` command and returns a clone command.
44+
45+
A successful response looks like:
46+
47+
```json
48+
{
49+
"name": "my-generated-worker",
50+
"artifactRepo": "my-generated-worker",
51+
"remote": "https://<account>.artifacts.cloudflare.net/git/workspace-artifacts-example/<repo>.git",
52+
"branch": "main",
53+
"projectDir": "/workspace/my-generated-worker",
54+
"shareLink": "https://x:<token>@<account>.artifacts.cloudflare.net/git/workspace-artifacts-example/<repo>.git",
55+
"cloneCommand": "git clone 'https://x:<token>@<account>.artifacts.cloudflare.net/git/workspace-artifacts-example/<repo>.git' my-generated-worker",
56+
"tokenExpiresAt": "2026-06-17T00:00:00.000Z"
57+
}
58+
```
59+
60+
Treat `shareLink` and `cloneCommand` as secrets. The embedded read token expires after 24 hours.
61+
62+
The Artifacts binding is configured with `remote: true`, so local `wrangler dev` talks to the remote Artifacts service.

examples/artifacts/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@example/workspace-artifacts",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "Example Worker that turns examples/worker into a cloneable Cloudflare Artifacts repo through @cloudflare/workspace.",
7+
"scripts": {
8+
"dev": "wrangler dev",
9+
"deploy": "wrangler deploy",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"@cloudflare/workspace": "*"
14+
},
15+
"devDependencies": {
16+
"@cloudflare/workers-types": "^4.20260616.1",
17+
"typescript": "^6.0.3",
18+
"wrangler": "^4.95.0"
19+
}
20+
}

examples/artifacts/src/index.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// Minimal Artifacts example.
2+
//
3+
// POST /create { "name": "my-worker" } builds a fresh copy of
4+
// examples/worker, rewrites its Worker name, publishes it to a new
5+
// Cloudflare Artifacts repo, and returns a read-only clone URL.
6+
// The Worker owns the endpoint logic. The durable object stays
7+
// minimal: it owns the Workspace, exposes getWorkspace(), and bridges
8+
// the host Artifacts binding into the worker-backend shell command.
9+
10+
import { DurableObject } from "cloudflare:workers";
11+
12+
import {
13+
type DurableObjectStorageLike,
14+
Workspace,
15+
WorkspaceServiceProxy,
16+
type WorkspaceStub,
17+
} from "@cloudflare/workspace";
18+
import { WorkerBackend, type WorkerBackendOptions } from "@cloudflare/workspace/backends/worker";
19+
20+
export { WorkspaceServiceProxy };
21+
22+
interface CreateRequest {
23+
name?: string;
24+
}
25+
26+
interface CreateResult {
27+
name: string;
28+
artifactRepo: string;
29+
remote: string;
30+
branch: string;
31+
projectDir: string;
32+
shareLink: string;
33+
cloneCommand: string;
34+
tokenExpiresAt: string;
35+
}
36+
37+
interface ArtifactCreateOutput {
38+
name: string;
39+
remote: string;
40+
token: string;
41+
}
42+
43+
interface TokenCreateOutput {
44+
plaintext: string;
45+
expiresAt: string;
46+
}
47+
48+
const WORKSPACE_ROOT = "/workspace";
49+
const SOURCE_REPO = "https://github.com/cloudflare/workspace";
50+
const EXAMPLE_PATH = "examples/worker";
51+
const SHARE_TOKEN_TTL_SECONDS = 24 * 60 * 60;
52+
53+
export class ArtifactCreator extends DurableObject<Env> {
54+
readonly #workspace: Workspace;
55+
56+
constructor(ctx: DurableObjectState, env: Env) {
57+
super(ctx, env);
58+
const workerBackendOptions: WorkerBackendOptions = {
59+
loader: env.LOADER as unknown as WorkerBackendOptions["loader"],
60+
workspace: { binding: "ArtifactCreator", id: ctx.id.toString() },
61+
ctx,
62+
};
63+
this.#workspace = new Workspace({
64+
storage: ctx.storage as unknown as DurableObjectStorageLike,
65+
sessionId: ctx.id.toString(),
66+
artifacts: { binding: env.ARTIFACTS },
67+
backends: [new WorkerBackend(workerBackendOptions)],
68+
});
69+
}
70+
71+
async getWorkspace(): Promise<WorkspaceStub> {
72+
await this.#workspace.ready();
73+
return this.#workspace.stub();
74+
}
75+
}
76+
77+
export default {
78+
async fetch(request: Request, env: Env): Promise<Response> {
79+
const url = new URL(request.url);
80+
81+
if (url.pathname === "/" && request.method === "GET") {
82+
return new Response(
83+
[
84+
"workspace artifacts example",
85+
"",
86+
"POST /create",
87+
' body: { "name": "my-worker" }',
88+
"",
89+
"curl -X POST https://<worker>/create \\",
90+
" -H 'content-type: application/json' \\",
91+
' -d \'{"name":"my-worker"}\'',
92+
"",
93+
"Builds examples/worker in a Workspace and pushes it to a",
94+
"new Cloudflare Artifacts repo.",
95+
].join("\n"),
96+
{ headers: { "content-type": "text/plain; charset=utf-8" } },
97+
);
98+
}
99+
100+
if (url.pathname === "/create") return handleCreate(request, env);
101+
102+
return new Response("not found", { status: 404 });
103+
},
104+
} satisfies ExportedHandler<Env>;
105+
106+
async function handleCreate(request: Request, env: Env): Promise<Response> {
107+
if (request.method !== "POST") {
108+
return new Response("method not allowed", { status: 405, headers: { allow: "POST" } });
109+
}
110+
111+
let body: CreateRequest;
112+
try {
113+
body = (await request.json()) as CreateRequest;
114+
} catch {
115+
return errorJSON(new Error("invalid JSON body"), 400);
116+
}
117+
118+
if (typeof body.name !== "string") {
119+
return errorJSON(new Error("name must be a string"), 400);
120+
}
121+
const name = body.name.trim();
122+
if (!isValidWorkerName(name)) {
123+
return errorJSON(
124+
new Error(
125+
"name must start with a lowercase letter or digit and contain only lowercase letters, digits, and hyphens",
126+
),
127+
400,
128+
);
129+
}
130+
131+
const stub = env.ArtifactCreator.get(env.ArtifactCreator.idFromName(name));
132+
// `wrangler types` exposes returned RpcTarget instances as
133+
// Rpc.Stub<T>, which loses the concrete overloads on nested
134+
// members. Runtime-wise this is still the WorkspaceStub surface,
135+
// so cast once at the boundary and keep the rest of the example
136+
// readable.
137+
const ws = (await stub.getWorkspace()) as unknown as WorkspaceStub;
138+
const sourceDir = `${WORKSPACE_ROOT}/${name}-source`;
139+
const projectDir = `${WORKSPACE_ROOT}/${name}`;
140+
let createdRepo = false;
141+
142+
try {
143+
await exec(
144+
ws,
145+
[
146+
`rm -rf ${shellQuote(sourceDir)} ${shellQuote(projectDir)}`,
147+
`git clone --depth 1 ${shellQuote(SOURCE_REPO)} ${shellQuote(sourceDir)}`,
148+
`mkdir -p ${shellQuote(projectDir)}`,
149+
`cp -R ${shellQuote(`${sourceDir}/${EXAMPLE_PATH}/.`)} ${shellQuote(projectDir)}`,
150+
`sed -i ${shellQuote(`s/"name"[[:space:]]*:[[:space:]]*"[^"]*"/"name": "${name}"/`)} ${shellQuote(`${projectDir}/wrangler.jsonc`)}`,
151+
`sed -i ${shellQuote(`s/"name"[[:space:]]*:[[:space:]]*"[^"]*"/"name": "@example\\/${name}"/`)} ${shellQuote(`${projectDir}/package.json`)}`,
152+
`git init --initial-branch=main ${shellQuote(projectDir)}`,
153+
`cat ${shellQuote(`${projectDir}/.git/HEAD`)} >/dev/null`,
154+
"git add .",
155+
`git commit -m ${shellQuote(`Create ${name} worker example`)} --author ${shellQuote("Cloudflare Workspace Artifacts Example <workspace-artifacts@example.invalid>")}`,
156+
].join(" && "),
157+
{ cwd: projectDir },
158+
);
159+
160+
// Make the demo easy to rerun with the same name. The repo is
161+
// session-scoped by createArtifact() inside the durable object,
162+
// so this only replaces the project this endpoint owns.
163+
await exec(ws, `artifacts repo delete ${shellQuote(name)} || true`);
164+
const created = parseJSON<ArtifactCreateOutput>(
165+
await exec(
166+
ws,
167+
[
168+
"artifacts repo create",
169+
shellQuote(name),
170+
"--default-branch main",
171+
`--description ${shellQuote(`Generated from ${SOURCE_REPO}/${EXAMPLE_PATH}`)}`,
172+
].join(" "),
173+
),
174+
);
175+
createdRepo = true;
176+
177+
const pushRemote = authenticatedArtifactRemote(created.remote, created.token);
178+
await exec(ws, `git push --force ${shellQuote(pushRemote)} HEAD:main`, {
179+
cwd: projectDir,
180+
secretToRedact: pushRemote,
181+
});
182+
183+
const readToken = parseJSON<TokenCreateOutput>(
184+
await exec(
185+
ws,
186+
`artifacts token create ${shellQuote(name)} --scope read --ttl ${SHARE_TOKEN_TTL_SECONDS}`,
187+
),
188+
);
189+
const shareLink = authenticatedArtifactRemote(created.remote, readToken.plaintext);
190+
191+
return Response.json({
192+
name,
193+
artifactRepo: created.name,
194+
remote: created.remote,
195+
branch: "main",
196+
projectDir,
197+
shareLink,
198+
cloneCommand: `git clone ${shellQuote(shareLink)} ${shellQuote(name)}`,
199+
tokenExpiresAt: readToken.expiresAt,
200+
} satisfies CreateResult);
201+
} catch (cause) {
202+
if (createdRepo) await exec(ws, `artifacts repo delete ${shellQuote(name)}`).catch(() => "");
203+
return errorJSON(cause, isAlreadyExists(cause) ? 409 : 500);
204+
} finally {
205+
ws[Symbol.dispose]?.();
206+
}
207+
}
208+
209+
async function exec(
210+
ws: WorkspaceStub,
211+
command: string,
212+
options: { cwd?: string; secretToRedact?: string } = {},
213+
): Promise<string> {
214+
const handle = await ws.shell.exec(command, { cwd: options.cwd, encoding: "utf8" });
215+
try {
216+
const result = await handle.result();
217+
if (result.exitCode === 0) return result.stdout;
218+
219+
const raw = result.stderr || result.stdout || `exit code ${result.exitCode}`;
220+
const output = options.secretToRedact
221+
? raw.replaceAll(options.secretToRedact, "<artifact-remote>")
222+
: raw;
223+
throw new Error(`command failed: ${output}`);
224+
} finally {
225+
handle[Symbol.dispose]?.();
226+
}
227+
}
228+
229+
function parseJSON<T>(text: string): T {
230+
return JSON.parse(text) as T;
231+
}
232+
233+
function isValidWorkerName(name: string): boolean {
234+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name);
235+
}
236+
237+
function isAlreadyExists(cause: unknown): boolean {
238+
return (
239+
cause instanceof Error &&
240+
cause.name === "ArtifactsError" &&
241+
(cause as { code?: unknown }).code === "ALREADY_EXISTS"
242+
);
243+
}
244+
245+
function authenticatedArtifactRemote(remote: string, token: string): string {
246+
const secret = token.split("?expires=", 1)[0];
247+
return `https://x:${encodeURIComponent(secret)}@${remote.slice("https://".length)}`;
248+
}
249+
250+
function errorJSON(error: unknown, status: number): Response {
251+
const message = error instanceof Error ? error.message : String(error);
252+
const code = (error as { code?: string }).code;
253+
return Response.json({ error: message, code }, { status });
254+
}
255+
256+
function shellQuote(arg: string): string {
257+
if (/^[A-Za-z0-9_\-+=:,./@%]+$/.test(arg)) return arg;
258+
return `'${arg.replace(/'/g, `'"'"'`)}'`;
259+
}

examples/artifacts/tsconfig.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"compilerOptions": {
3+
"target": "esnext",
4+
"lib": ["esnext"],
5+
"module": "esnext",
6+
"moduleResolution": "bundler",
7+
"types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"],
8+
"esModuleInterop": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"strict": true,
11+
"skipLibCheck": true,
12+
"resolveJsonModule": true,
13+
"isolatedModules": true,
14+
"noEmit": true
15+
},
16+
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
17+
}

0 commit comments

Comments
 (0)