diff --git a/README.md b/README.md index 7c20e13..9abf51d 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,28 @@ xapi workers logs --env production --tail --since 10m xapi workers logs --env production --request-id ``` +Web projects can declare their browser build separately from Worker modules. +The CLI preserves supported Wrangler `assets` settings and uploads the files +through xAPI as Cloudflare native static assets: + +```json +{ + "assets": { + "directory": "dist/client", + "binding": "ASSETS", + "notFoundHandling": "single-page-application", + "runWorkerFirst": ["/api/*"] + } +} +``` + +`workers plan` shows whether the selected environment has a dedicated hostname. +When `webAppReady` is false, production promotion asks you to review the base +path, root-relative routes, and OAuth callbacks without blocking applications +that deliberately support path-prefix hosting. The current JSON Artifact +transport accepts 12 MiB of decoded Worker modules and static assets per +deployment. + Templates are versioned packages shipped with the CLI, not remote code fetched during `init`. `persistent-agent` includes buildable source plus KV, D1, R2, Durable Object, Queue, and Workflow declarations. `push` provisions the @@ -650,3 +672,11 @@ current IDs and schemas. ## License MIT + +### Native framework deployment bundles + +Framework output can be exported with Wrangler's `deploy --dry-run --outfile +dist/app.worker.bundle` and published through `xapi workers push`. The CLI +retains native module names/types/bytes and separately publishes static Assets. +See [the Workers guide](skills/xapi/guides/workers.md#framework-builds-publish-wranglers-complete-bundle) +for configuration, supported metadata and current transport boundaries. diff --git a/bun.lock b/bun.lock index dea379e..06bfe01 100644 --- a/bun.lock +++ b/bun.lock @@ -7,12 +7,14 @@ "dependencies": { "@openai/agents": "0.15.0", "acorn": "^8.18.0", + "busboy": "1.6.0", "jsonc-parser": "^3.3.1", "smol-toml": "^1.8.0", "zod": "^4.0.0", }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/busboy": "1.5.4", "@types/node": "^18", "tsup": "^8.5.1", "typescript": "^6.0.3", @@ -144,6 +146,8 @@ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], @@ -158,6 +162,8 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -240,6 +246,8 @@ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -268,12 +276,16 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@types/busboy/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "@types/ws/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], "bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], "mlly/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@types/busboy/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index cc3ceac..6d2872f 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,14 @@ "dependencies": { "@openai/agents": "0.15.0", "acorn": "^8.18.0", + "busboy": "1.6.0", "jsonc-parser": "^3.3.1", "smol-toml": "^1.8.0", "zod": "^4.0.0" }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/busboy": "1.5.4", "@types/node": "^18", "tsup": "^8.5.1", "typescript": "^6.0.3" diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json index b8f8372..3f31918 100644 --- a/schemas/worker-project.v1.schema.json +++ b/schemas/worker-project.v1.schema.json @@ -39,6 +39,45 @@ } } }, + "assets": { + "type": "object", + "additionalProperties": false, + "required": ["directory"], + "properties": { + "directory": { "$ref": "#/$defs/projectPath" }, + "binding": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "htmlHandling": { + "enum": [ + "auto-trailing-slash", + "force-trailing-slash", + "drop-trailing-slash", + "none" + ] + }, + "notFoundHandling": { + "enum": ["none", "404-page", "single-page-application"] + }, + "runWorkerFirst": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": "^!?/" + } + } + ] + } + } + }, "environments": { "type": "object", "additionalProperties": false, diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index af14293..6abd884 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -70,7 +70,7 @@ Use granular commands only for multi-step work. Keep the instance ID, terminate ## Hosted Workers -Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. Use `init --from-wrangler` for an existing Cloudflare Worker. Git is optional. `push` builds and uploads an immutable Artifact, uses stable recovery keys, and never silently deletes stateful resources or Secrets; an optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue messages use the documented route envelope, are delivered at least once, and require an idempotent target route. Only `ACTIVE` means deployment succeeded. +Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. Use `init --from-wrangler` for an existing Cloudflare Worker. Git is optional. `push` builds and uploads an immutable Artifact, including separately declared native static assets, uses stable recovery keys, and never silently deletes stateful resources or Secrets. For web applications, inspect `webAppReady`: path-prefix-aware applications can use fallback routing, while root-relative routes and OAuth callbacks need a dedicated hostname. An optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue messages use the documented route envelope, are delivered at least once, and require an idempotent target route. Only `ACTIVE` means deployment succeeded. ## Usage Workflow diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 1f5bfc7..0940592 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -103,6 +103,49 @@ package imports must be bundled by the build. The CLI normalizes and hashes the complete Artifact before `plan` or `push`, so both commands compare identical bytes. Existing single-file project configurations remain valid. +### Framework builds: publish Wrangler's complete bundle + +For a framework that produces a generated Wrangler configuration (for example +vinext), use that configuration to produce the native upload bundle: + +```bash +npm run build +npx wrangler deploy --dry-run --config dist/server/wrangler.json --outfile dist/app.worker.bundle +``` + +Point the project build output to `dist/app.worker.bundle`; omit `build.main`. +Set `assets.directory` to the framework's client output (for example +`dist/client`). Then use `xapi workers plan --env preview` and +`xapi workers push --env preview`. The build command should run both commands +above. `--dry-run` creates a local artifact; it does not publish outside xAPI. + +The CLI reads multipart module names, bytes, MIME types and `main_module` from +Wrangler instead of guessing the output directory's contents. It does not +rename chunks or rewrite imports. Assets are packaged with the artifact and +published using CF's asset upload session before the script is activated. +Compatibility date/flags must match the project's Wrangler configuration. +D1/R2/KV binding names must match declared xAPI resources; native account IDs +and resource IDs are not reused. Secrets are set separately through xAPI. +The artifact also preserves `observability.enabled`. + +This adapter currently supports the explicitly mapped metadata above, not every +Wrangler setting. Unmapped metadata fails before artifact upload rather than +being silently discarded. Cron triggers are separate from the upload bundle +and must be configured through xAPI schedules. The granular `workers upload` +command is artifact-only; use the project `push` workflow for coordinated +compatibility, resource, secret and asset handling. + +Current xAPI transport limits remain 200 modules / 10 MiB decoded modules and +12 MiB decoded modules plus assets. These are xAPI limits, not a statement of +CF's full native capacity. If exceeded, report the unsupported deployment; +never split a project into unrelated deployments or edit framework output to +work around the limit. + +A `PATH_FALLBACK` URL is not a root-hosted Web application URL. Do not rewrite +application routes or configure GitHub callbacks against an invented host. +Use the environment's reported routing state and verify a real reachable +`publicOrigin` with empty `publicBasePath` for a root-hosted acceptance test. + After real preview validation, promote the exact active preview Artifact without rebuilding it: @@ -208,12 +251,30 @@ npx xapi-to workers upload \ --idempotency-key artifact-2026-08-21 ``` -This directory format is for Worker code modules. HTML, CSS, images, fonts, and -other website files are static assets and use Cloudflare's separate assets -upload protocol; the CLI rejects them here instead of silently dropping them. -Native static-assets upload is not exposed by this xAPI CLI flow yet. Until it -is, bundle small application assets into Worker code through the project's -build step; never bypass xAPI by sending the user's key directly to Cloudflare. +This directory format is for Worker code modules. For a web application, keep +HTML, CSS, images, and fonts in a separate build directory and declare it in +`xapi.worker.json`. `workers push` packages those files into the immutable xAPI +Artifact and the platform completes Cloudflare's native static-assets upload: + +```json +{ + "build": { "command": "npm run build", "output": "dist/worker" }, + "assets": { + "directory": "dist/client", + "binding": "ASSETS", + "htmlHandling": "auto-trailing-slash", + "notFoundHandling": "single-page-application", + "runWorkerFirst": ["/api/*"] + } +} +``` + +Wrangler imports preserve supported `assets` settings. Cloudflare permits up to +25 MiB per asset and 100,000 assets per version. Asset content stays separate +from Worker modules and is never silently dropped. The current xAPI JSON +Artifact transport accepts at most 12 MiB of decoded modules and assets in one +deployment; split larger sites before upload until the multipart Artifact +transport is available. Save the returned Artifact `id`, then deploy that exact Artifact to preview: @@ -225,7 +286,12 @@ npx xapi-to workers deploy \ --idempotency-key release-candidate-1 ``` -After deployment, read the environment `publicUrl` instead of constructing a hostname: +After deployment, read the environment `publicUrl` instead of constructing a hostname. +For a web application, `workers plan` reports whether that environment has a +dedicated hostname. Preview path fallback remains useful for API and diagnostic +Workers. A path-prefix-aware application can also use it in production; +root-relative browser URLs and OAuth callbacks require `webAppReady: true`. +Promotion surfaces this as a manual review instead of blocking compatible apps. ```bash npx xapi-to workers get --format pretty diff --git a/src/commands/workers.ts b/src/commands/workers.ts index 419ecd2..c16e124 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -25,7 +25,7 @@ import { rollbackWorkerProject } from "../workers-rollback.ts"; import { readWorkerLogs, tailWorkerLogs } from "../workers-logs.ts"; import { formatWorkerMetering } from "../workers-metering-output.ts"; import { - loadWorkerArtifact, + loadWorkerArtifactInput, WorkerArtifactError, } from "../workers-artifact.ts"; import { @@ -665,7 +665,7 @@ export async function workersCommand( } let artifact; try { - artifact = loadWorkerArtifact( + artifact = await loadWorkerArtifactInput( resolve(required(flags.file, "--file")), flags.main, ); diff --git a/src/tests/skill-workers-guide.test.ts b/src/tests/skill-workers-guide.test.ts index 9b1ecf6..26b055d 100644 --- a/src/tests/skill-workers-guide.test.ts +++ b/src/tests/skill-workers-guide.test.ts @@ -40,7 +40,9 @@ describe('bundled xAPI Workers skill guide', () => { expect(guide).toContain('"main": "worker.js"'); expect(guide).toContain('--file dist/'); expect(guide).toContain('--main worker.js'); - expect(guide).toContain("separate assets\nupload protocol"); + expect(guide).toContain("native static-assets upload"); + expect(guide).toContain('"directory": "dist/client"'); + expect(guide).toContain('`webAppReady: true`'); expect(guide).not.toContain('--build '); }); diff --git a/src/tests/workers-artifact.test.ts b/src/tests/workers-artifact.test.ts index 837105e..abc3a7a 100644 --- a/src/tests/workers-artifact.test.ts +++ b/src/tests/workers-artifact.test.ts @@ -97,6 +97,37 @@ describe("Worker Artifact loader", () => { expect(() => loadWorkerArtifact(root)).toThrow("--main"); }); + test("packages native static assets with MIME types and routing settings", () => { + const root = directory(); + const worker = join(root, "worker.mjs"); + const assets = join(root, "public"); + mkdirSync(assets); + writeFileSync(worker, "export default { fetch() { return new Response('api') } };"); + writeFileSync(join(assets, "index.html"), "

hello

"); + writeFileSync(join(assets, "logo.png"), Buffer.from([137, 80, 78, 71])); + + const artifact = loadWorkerArtifact(worker, undefined, { + directory: assets, + binding: "ASSETS", + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }); + + expect(artifact.kind).toBe("bundle"); + if (!("bundle" in artifact.upload)) throw new Error("expected bundle"); + expect(artifact.upload.bundle.assets).toEqual({ + binding: "ASSETS", + config: { + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }, + files: [ + expect.objectContaining({ path: "/index.html", contentType: "text/html" }), + expect.objectContaining({ path: "/logo.png", contentType: "image/png" }), + ], + }); + }); + test("rejects missing relative modules and static website assets", () => { const root = directory(); writeFileSync( diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts new file mode 100644 index 0000000..d9fbec6 --- /dev/null +++ b/src/tests/workers-native-bundle.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadWorkerArtifactInput, validateNativeDeploymentMetadata } from '../workers-artifact.ts'; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root,{recursive:true,force:true}); }); +function bundle(parts: Array<{name:string; type?:string; content:string|Buffer}>) { + const root=mkdtempSync(join(tmpdir(),'native-bundle-')); roots.push(root); + const path=join(root,'worker.bundle'); const boundary='native-test-boundary'; + writeFileSync(path,Buffer.concat(parts.flatMap(p=>[ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"${p.type?`; filename="${p.name}"`:''}\r\n${p.type?`Content-Type: ${p.type}\r\n`:''}\r\n`), + Buffer.from(p.content),Buffer.from('\r\n'), + ]).concat([Buffer.from(`--${boundary}--\r\n`)]))); + return path; +} +const metadata={name:'metadata',content:JSON.stringify({main_module:'index.js',compatibility_date:'2026-09-10',compatibility_flags:['nodejs_compat'],bindings:[]})}; +const entry={name:'index.js',type:'application/javascript+module',content:'export default {fetch(){return import("./chunk~rsc.js")}};'}; +test('preserves native multipart module bytes, names, MIME and computed imports',async()=>{ + const binary=Buffer.from([0,255,10,13,0,128]); + const a=await loadWorkerArtifactInput(bundle([metadata,entry,{name:'chunk~rsc.js',type:'application/javascript+module',content:'export const load = path => import(path);'},{name:'data.wasm',type:'application/wasm',content:binary}])); + if(!('bundle' in a.upload)) throw Error('bundle'); + expect(a.upload.bundle.modules.find(m=>m.path==='chunk~rsc.js')?.content).toBe('export const load = path => import(path);'); + expect(a.upload.bundle.modules.find(m=>m.path==='data.wasm')?.content).toBe(binary.toString('base64')); + validateNativeDeploymentMetadata(a,{compatibilityDate:'2026-09-10',compatibilityFlags:['nodejs_compat']},[]); + expect(()=>validateNativeDeploymentMetadata(a,{compatibilityDate:'2020-01-01'},[])).toThrow('compatibility'); +}); +test('rejects duplicate names and traversal before upload',async()=>{ + await expect(loadWorkerArtifactInput(bundle([metadata,entry,entry]))).rejects.toThrow('duplicate'); + await expect(loadWorkerArtifactInput(bundle([metadata,entry,{name:'../escape.js',type:entry.type,content:'export{}'}]))).rejects.toThrow('Invalid'); +}); +test('rejects missing and duplicate metadata',async()=>{ + await expect(loadWorkerArtifactInput(bundle([entry]))).rejects.toThrow('metadata'); + await expect(loadWorkerArtifactInput(bundle([metadata,metadata,entry]))).rejects.toThrow(); +}); +test('rejects private binding metadata and unsupported native properties',async()=>{ + await expect(loadWorkerArtifactInput(bundle([{...metadata,content:JSON.stringify({main_module:'index.js',bindings:[{name:'SECRET',type:'secret_text',text:'test-only'}]})},entry]))).rejects.toThrow('Secrets'); + await expect(loadWorkerArtifactInput(bundle([{...metadata,content:JSON.stringify({main_module:'index.js',limits:{cpu_ms:100}})},entry]))).rejects.toThrow('mapping'); +}); +test('requires declared native bindings and own main_module',async()=>{ + const path=bundle([{...metadata,content:JSON.stringify({main_module:'index.js',compatibility_date:'2026-09-10',bindings:[{name:'DB',type:'d1',id:'foreign-id'}]})},entry]); + const a=await loadWorkerArtifactInput(path); + expect(()=>validateNativeDeploymentMetadata(a,{compatibilityDate:'2026-09-10'},[])).toThrow('DB'); + validateNativeDeploymentMetadata(a,{compatibilityDate:'2026-09-10'},[{bindingName:'DB',type:'d1_database'}]); + expect(JSON.stringify(a.upload)).not.toContain('foreign-id'); + await expect(loadWorkerArtifactInput(path,'index.js')).rejects.toThrow('omit'); +}); + +test('preserves observability in artifact identity and rejects unmapped settings', async () => { + const path = bundle([{...metadata, content:JSON.stringify({...JSON.parse(metadata.content),observability:{enabled:true}})},entry]); + const a = await loadWorkerArtifactInput(path); + if (!('bundle' in a.upload)) throw Error('bundle'); + expect(a.upload.bundle.observability).toEqual({enabled:true}); + const plain = await loadWorkerArtifactInput(bundle([metadata,entry])); + expect(a.contentSha256).not.toBe(plain.contentSha256); + await expect(loadWorkerArtifactInput(bundle([{...metadata,content:JSON.stringify({...JSON.parse(metadata.content),observability:{enabled:true,unknown:true}})},entry]))).rejects.toThrow('mapping'); +}); diff --git a/src/tests/workers-project.test.ts b/src/tests/workers-project.test.ts index 1c1e7ea..c44a94b 100644 --- a/src/tests/workers-project.test.ts +++ b/src/tests/workers-project.test.ts @@ -95,6 +95,23 @@ describe("Worker project configuration", () => { expect(() => loadWorkerProject(root)).toThrow("build.output"); }); + test("accepts Cloudflare-native static asset routing", () => { + const root = fixture({ + assets: { + directory: "dist/client", + binding: "ASSETS", + htmlHandling: "auto-trailing-slash", + runWorkerFirst: ["/api/*", "!/api/docs/*"], + }, + }); + expect(loadWorkerProject(root).config.assets).toEqual({ + directory: "dist/client", + binding: "ASSETS", + htmlHandling: "auto-trailing-slash", + runWorkerFirst: ["/api/*", "!/api/docs/*"], + }); + }); + test("rejects credential fields and credential-shaped values", () => { const fieldRoot = fixture({ apiKey: "placeholder" }); expect(() => loadWorkerProject(fieldRoot)).toThrow( diff --git a/src/tests/workers-promote.test.ts b/src/tests/workers-promote.test.ts index f2d4773..ba9da3a 100644 --- a/src/tests/workers-promote.test.ts +++ b/src/tests/workers-promote.test.ts @@ -20,7 +20,7 @@ afterEach(() => { } }); -function fixture(): string { +function fixture(options: { assets?: boolean } = {}): string { const root = realpathSync(mkdtempSync(join(tmpdir(), "xapi-promote-"))); roots.push(root); writeFileSync( @@ -51,6 +51,15 @@ function fixture(): string { }, wrangler: "wrangler.jsonc", build: { command: "never-run", output: "dist/worker.mjs" }, + ...(options.assets + ? { + assets: { + directory: "dist/client", + binding: "ASSETS", + notFoundHandling: "single-page-application", + }, + } + : {}), environments: { preview: { dailyBudgetUsd: 0.25, @@ -79,6 +88,7 @@ function fakePlatform( resources?: Array>; secrets?: string[]; failDeployOnce?: boolean; + webAppReady?: boolean; } = {}, ) { const previewDeployments: Array> = [ @@ -134,6 +144,7 @@ function fakePlatform( activeDeploymentId: productionDeployments.find(item => item.status === "ACTIVE")?.id, dailyBudgetUsd: options.budget ?? 2, publicUrl: "https://agent.example.test/w/ref/production", + webAppReady: options.webAppReady, }, ], artifacts, @@ -277,6 +288,34 @@ describe("workers promote", () => { expect(platform.calls.health).toBe(0); }); + test("surfaces path-fallback review without blocking compatible static web apps", async () => { + const root = fixture({ assets: true }); + const fallback = fakePlatform({ webAppReady: false }); + const blocked = await createWorkerPromotionPlan({ + cwd: root, + to: "production", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: fallback.client, + }); + expect(blocked.plan.canPromote).toBe(true); + expect(blocked.plan.production.checks).toContainEqual( + expect.objectContaining({ + status: "MANUAL", + kind: "routing", + key: "production", + }), + ); + + const dedicated = fakePlatform({ webAppReady: true }); + const ready = await createWorkerPromotionPlan({ + cwd: root, + to: "production", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: dedicated.client, + }); + expect(ready.plan.canPromote).toBe(true); + }); + test("shows extra production state as MANUAL data risk and cancellation is mutation-free", async () => { const root = fixture(); const platform = fakePlatform({ diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index b6e1a61..9571162 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -36,6 +36,13 @@ describe("Wrangler project import", () => { "main": "src/index.ts", "compatibility_date": "2026-08-26", "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "dist/client", + "binding": "ASSETS", + "html_handling": "auto-trailing-slash", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*"] + }, "account_id": "provider-account-id", "routes": ["old.example/*"], "kv_namespaces": [{ "binding": "STATE", "id": "physical-kv-id" }], @@ -90,6 +97,13 @@ describe("Wrangler project import", () => { ), ).toEqual(["AGENT", "DB", "EVENTS", "FILES", "FLOW", "STATE"]); expect(project.config.environments.preview.secrets).toEqual(["MODEL_KEY"]); + expect(project.config.assets).toEqual({ + directory: "dist/client", + binding: "ASSETS", + htmlHandling: "auto-trailing-slash", + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }); const generated = readFileSync(join(root, "xapi.worker.json"), "utf8"); expect(generated).not.toContain("provider-account-id"); expect(generated).not.toContain("physical-"); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index 1f872a1..954b57d 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -1,3 +1,4 @@ +import busboy from "busboy"; import { createHash } from "node:crypto"; import { existsSync, @@ -12,8 +13,13 @@ import { parse } from "acorn"; const MAX_LEGACY_ARTIFACT_BYTES = 1024 * 1024; const MAX_BUNDLE_CONTENT_BYTES = 10 * 1024 * 1024; const MAX_BUNDLE_MODULES = 200; +const MAX_ASSET_FILES = 100_000; +const MAX_ASSET_FILE_BYTES = 25 * 1024 * 1024; +// The current xAPI JSON Artifact endpoint has a 20 MiB request-body ceiling. +// Base64 expansion leaves 12 MiB for decoded Worker modules plus assets. +const MAX_XAPI_ARTIFACT_CONTENT_BYTES = 12 * 1024 * 1024; const SAFE_MODULE_PATH = - /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@+/-]{1,240}$/; + /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@+~/-]{1,240}$/; type UnknownRecord = Record; @@ -30,10 +36,37 @@ export interface WorkerArtifactBundleModule { contentType: WorkerModuleContentType; } +export interface WorkerArtifactAsset { + path: string; + content: string; + encoding: "base64"; + contentType: string; +} + +export interface WorkerArtifactAssets { + files: WorkerArtifactAsset[]; + binding?: string; + config?: { + htmlHandling?: "auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none"; + notFoundHandling?: "none" | "404-page" | "single-page-application"; + runWorkerFirst?: boolean | string[]; + }; +} + +export interface WorkerStaticAssetsInput { + directory: string; + binding?: string; + htmlHandling?: "auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none"; + notFoundHandling?: "none" | "404-page" | "single-page-application"; + runWorkerFirst?: boolean | string[]; +} + export interface WorkerArtifactBundle { version: 1; mainModule: string; modules: WorkerArtifactBundleModule[]; + observability?: { enabled: boolean }; + assets?: WorkerArtifactAssets; } export type WorkerArtifactUploadInput = @@ -49,6 +82,7 @@ export interface LoadedWorkerArtifact { contentSha256: string; sizeBytes: number; upload: WorkerArtifactUploadInput; + nativeMetadata?: UnknownRecord; } export class WorkerArtifactError extends Error { @@ -170,6 +204,79 @@ function moduleContentType(path: string): WorkerModuleContentType | undefined { return undefined; } +function assetContentType(path: string): string { + const extension = posix.extname(path).toLowerCase(); + return ({ + ".avif": "image/avif", ".css": "text/css", ".csv": "text/csv", + ".gif": "image/gif", ".html": "text/html", ".ico": "image/x-icon", + ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".js": "text/javascript", + ".json": "application/json", ".map": "application/json", ".mjs": "text/javascript", + ".pdf": "application/pdf", ".png": "image/png", ".svg": "image/svg+xml", + ".txt": "text/plain", ".wasm": "application/wasm", ".webmanifest": "application/manifest+json", + ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", + ".xml": "application/xml", ".zip": "application/zip", + } as Record)[extension] || "application/octet-stream"; +} + +function collectAssetFiles(input: WorkerStaticAssetsInput): WorkerArtifactAssets { + const root = resolve(input.directory); + if (!existsSync(root)) throw new WorkerArtifactError(`Static assets directory does not exist: ${root}`); + if (lstatSync(root).isSymbolicLink() || !statSync(root).isDirectory()) { + throw new WorkerArtifactError("Static assets path must be a directory and not a symbolic link"); + } + const files: WorkerArtifactAsset[] = []; + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = resolve(directory, entry.name); + const relativePath = portableRelativePath(root, absolute); + const info = lstatSync(absolute); + if (info.isSymbolicLink()) throw new WorkerArtifactError(`Static assets must not contain symbolic links: ${relativePath}`); + if (info.isDirectory()) { walk(absolute); continue; } + if (!info.isFile()) throw new WorkerArtifactError(`Static assets contain an unsupported filesystem entry: ${relativePath}`); + if (!relativePath || relativePath.includes("\\") || relativePath.split("/").includes("..") || /[\u0000-\u001f\u007f]/.test(relativePath)) { + throw new WorkerArtifactError(`Static asset path is invalid: ${relativePath}`); + } + if (info.size > MAX_ASSET_FILE_BYTES) throw new WorkerArtifactError(`Static asset exceeds Cloudflare's 25 MiB per-file limit: ${relativePath}`); + const bytes = readFileSync(absolute); + files.push({ path: `/${relativePath}`, content: bytes.toString("base64"), encoding: "base64", contentType: assetContentType(relativePath) }); + if (files.length > MAX_ASSET_FILES) throw new WorkerArtifactError(`Static assets exceed Cloudflare's ${MAX_ASSET_FILES} file limit`); + } + }; + walk(root); + if (!files.length) throw new WorkerArtifactError("Static assets directory is empty"); + const config = { + ...(input.htmlHandling ? { htmlHandling: input.htmlHandling } : {}), + ...(input.notFoundHandling ? { notFoundHandling: input.notFoundHandling } : {}), + ...(input.runWorkerFirst !== undefined ? { runWorkerFirst: input.runWorkerFirst } : {}), + }; + return { + files: files.sort((a, b) => a.path.localeCompare(b.path)), + ...(input.binding ? { binding: input.binding } : {}), + ...(Object.keys(config).length ? { config } : {}), + }; +} + +function assertArtifactContentLimit(bundle: WorkerArtifactBundle): void { + const moduleBytes = bundle.modules.reduce( + (total, module) => + total + + (module.encoding === "base64" + ? Buffer.from(module.content, "base64").length + : Buffer.byteLength(module.content, "utf8")), + 0, + ); + const assetBytes = + bundle.assets?.files.reduce( + (total, asset) => total + Buffer.from(asset.content, "base64").length, + 0, + ) || 0; + if (moduleBytes + assetBytes > MAX_XAPI_ARTIFACT_CONTENT_BYTES) { + throw new WorkerArtifactError( + "Worker modules and static assets exceed the current xAPI Artifact transport limit of 12 MiB", + ); + } +} + function portableRelativePath(root: string, path: string): string { return relative(root, path).split(sep).join("/"); } @@ -215,6 +322,36 @@ function loadSingleModule(path: string): LoadedWorkerArtifact { }; } +function loadSingleModuleWithAssets(path: string, staticAssets: WorkerStaticAssetsInput): LoadedWorkerArtifact { + const legacy = loadSingleModule(path); + if (!("moduleCode" in legacy.upload)) throw new WorkerArtifactError("Worker module could not be loaded"); + const mainModule = posix.basename(path); + const assets = collectAssetFiles(staticAssets); + const bundle: WorkerArtifactBundle = { + version: 1, + mainModule, + modules: [{ + path: mainModule, + content: legacy.upload.moduleCode, + encoding: "utf8", + contentType: "application/javascript+module", + }], + assets, + }; + assertArtifactContentLimit(bundle); + const storedBytes = Buffer.from(JSON.stringify({ + version: 1, + mainModule, + modules: [{ + path: mainModule, + contentBase64: Buffer.from(legacy.upload.moduleCode, "utf8").toString("base64"), + contentType: "application/javascript+module", + }], + assets, + }), "utf8"); + return { kind: "bundle", contentSha256: sha256(storedBytes), sizeBytes: storedBytes.length, upload: { bundle } }; +} + function collectBundleFiles(root: string): Array<{ path: string; bytes: Buffer; @@ -267,7 +404,7 @@ function collectBundleFiles(root: string): Array<{ return files.sort((a, b) => a.path.localeCompare(b.path)); } -function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerArtifact { +function loadModuleBundle(root: string, main: string | undefined, staticAssets?: WorkerStaticAssetsInput): LoadedWorkerArtifact { const mainModule = normalizeMainModule(main); const files = collectBundleFiles(root); if (!files.length) throw new WorkerArtifactError("Worker output directory is empty"); @@ -306,6 +443,7 @@ function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerA } } + const assets = staticAssets ? collectAssetFiles(staticAssets) : undefined; const bundle: WorkerArtifactBundle = { version: 1, mainModule, @@ -320,7 +458,9 @@ function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerA contentType: file.contentType, }; }), + ...(assets ? { assets } : {}), }; + assertArtifactContentLimit(bundle); const storedBytes = Buffer.from( JSON.stringify({ version: 1, @@ -330,6 +470,7 @@ function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerA contentBase64: file.bytes.toString("base64"), contentType: file.contentType, })), + ...(assets ? { assets } : {}), }), "utf8", ); @@ -344,6 +485,7 @@ function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerA export function loadWorkerArtifact( outputPath: string, mainModule?: string, + staticAssets?: WorkerStaticAssetsInput, ): LoadedWorkerArtifact { if (!existsSync(outputPath)) { throw new WorkerArtifactError(`Worker build output does not exist: ${outputPath}`); @@ -358,8 +500,117 @@ export function loadWorkerArtifact( "build.main (or --main) is only valid when the Worker build output is a directory", ); } - return loadSingleModule(outputPath); + return staticAssets + ? loadSingleModuleWithAssets(outputPath, staticAssets) + : loadSingleModule(outputPath); } - if (info.isDirectory()) return loadModuleBundle(outputPath, mainModule); + if (info.isDirectory()) return loadModuleBundle(outputPath, mainModule, staticAssets); throw new WorkerArtifactError("Worker build output is not a file or directory"); } + +/** Read Wrangler's --dry-run --outfile multipart artifact without rewriting code. */ +export async function loadWorkerArtifactInput( + outputPath: string, + mainModule?: string, + staticAssets?: WorkerStaticAssetsInput, +): Promise { + if (!outputPath.endsWith(".bundle")) return loadWorkerArtifact(outputPath, mainModule, staticAssets); + if (mainModule) throw new WorkerArtifactError("Wrangler bundles contain their own main_module; omit --main/build.main"); + if (!existsSync(outputPath) || !lstatSync(outputPath).isFile() || lstatSync(outputPath).isSymbolicLink()) { + throw new WorkerArtifactError("Wrangler bundle must be a regular file"); + } + if (statSync(outputPath).size > 18 * 1024 * 1024) throw new WorkerArtifactError("Wrangler bundle exceeds the current Artifact transport limit"); + const bytes = readFileSync(outputPath); + const firstLine = bytes.subarray(0, bytes.indexOf("\r\n")).toString("ascii"); + if (!/^--[A-Za-z0-9_-]{1,70}$/.test(firstLine)) throw new WorkerArtifactError("Invalid Wrangler multipart boundary"); + type NativeFile = { name: string; type: string; arrayBuffer(): Promise }; + const entries = await new Promise>((resolve, reject) => { + const result: Array<[string, string | NativeFile]> = []; + const parser = busboy({ headers: {"content-type": `multipart/form-data; boundary=${firstLine.slice(2)}`}, preservePath: true, + limits: { files: MAX_BUNDLE_MODULES, fields: 1, parts: MAX_BUNDLE_MODULES + 1, fieldSize: 1024 * 1024, fileSize: MAX_BUNDLE_CONTENT_BYTES } }); + parser.on("field", (name, value, info) => { + if (info.valueTruncated || info.nameTruncated) reject(new WorkerArtifactError("Truncated native metadata")); + result.push([name, value]); + }); + parser.on("file", (name, stream, info) => { + const chunks: Buffer[] = []; + stream.on("data", chunk => chunks.push(chunk)); + stream.on("limit", () => reject(new WorkerArtifactError("Native module exceeds Artifact capacity"))); + stream.on("error", reject); + stream.on("end", () => result.push([name, {name: info.filename, type: info.mimeType, arrayBuffer: async () => Buffer.concat(chunks)}])); + }); + for (const event of ["partsLimit", "filesLimit", "fieldsLimit"] as const) parser.on(event, () => reject(new WorkerArtifactError("Native multipart exceeds Artifact capacity"))); + parser.on("error", () => reject(new WorkerArtifactError("Malformed Wrangler multipart bundle"))); + parser.on("close", () => resolve(result)); + parser.end(bytes); + }); + const metadataParts = entries.filter(([name]) => name === "metadata"); + if (metadataParts.length !== 1 || typeof metadataParts[0][1] !== "string") throw new WorkerArtifactError("Wrangler bundle requires one metadata part"); + let metadata: UnknownRecord; + try { metadata = JSON.parse(metadataParts[0][1]); } + catch { throw new WorkerArtifactError("Invalid Wrangler metadata JSON"); } + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new WorkerArtifactError("Invalid Wrangler metadata"); + // Resource identities and credentials are owned by xAPI's control plane. + // Do not silently import a native binding that has no managed equivalent here. + const known = new Set(["main_module", "bindings", "compatibility_date", "compatibility_flags", "observability"]); + const unknown = Object.keys(metadata).filter(key => !known.has(key)); + if (unknown.length) throw new WorkerArtifactError(`Native metadata needs explicit platform mapping: ${unknown.join(", ")}`); + if (metadata.bindings !== undefined && (!Array.isArray(metadata.bindings) || metadata.bindings.some((binding: UnknownRecord) => + !binding || !["d1", "r2_bucket", "kv_namespace"].includes(String(binding.type)) || typeof binding.name !== "string" + ))) throw new WorkerArtifactError("Native binding metadata needs explicit platform mapping; keep credentials in xAPI Secrets"); + if (metadata.compatibility_flags !== undefined && (!Array.isArray(metadata.compatibility_flags) || metadata.compatibility_flags.some(flag => typeof flag !== "string"))) throw new WorkerArtifactError("Invalid native compatibility flags"); + const observation = metadata.observability as UnknownRecord | undefined; + if (observation !== undefined && (!observation || typeof observation !== "object" || Array.isArray(observation) || typeof observation.enabled !== "boolean" || Object.keys(observation).some(key => key !== "enabled"))) throw new WorkerArtifactError("Native observability config needs explicit mapping"); + const observability = observation ? {enabled: observation.enabled as boolean} : undefined; + const main = normalizeMainModule(typeof metadata.main_module === "string" ? metadata.main_module : undefined); + const modules: WorkerArtifactBundleModule[] = []; + const seen = new Set(); + let moduleBytes = 0; + const types = new Set(["application/javascript+module", "application/wasm", "text/plain", "application/octet-stream"]); + for (const [name, value] of entries) { + if (name === "metadata") continue; + if (typeof value === "string" || !SAFE_MODULE_PATH.test(name) || seen.has(name) || value.name !== name) throw new WorkerArtifactError(`Invalid or duplicate native module: ${name}`); + seen.add(name); + const contentType = value.type as WorkerModuleContentType; + if (!types.has(contentType)) throw new WorkerArtifactError(`Native module type needs platform mapping: ${contentType}`); + const content = Buffer.from(await value.arrayBuffer()); + moduleBytes += content.length; + if (moduleBytes > MAX_BUNDLE_CONTENT_BYTES || seen.size > MAX_BUNDLE_MODULES) throw new WorkerArtifactError("Native modules exceed current Artifact capacity"); + const utf8 = contentType === "application/javascript+module" || contentType === "text/plain"; + if (utf8 && !Buffer.from(content.toString("utf8"), "utf8").equals(content)) throw new WorkerArtifactError(`Invalid UTF-8 module: ${name}`); + // Native module linkage (including computed imports) is validated by CF. + if (contentType === "application/javascript+module") { + try { parse(content.toString("utf8"), { ecmaVersion: "latest", sourceType: "module", allowHashBang: true }); } + catch { throw new WorkerArtifactError(`Invalid JavaScript module: ${name}`); } + } + modules.push({ path: name, content: content.toString(utf8 ? "utf8" : "base64"), encoding: utf8 ? "utf8" : "base64", contentType }); + } + if (!modules.some(module => module.path === main && module.contentType === "application/javascript+module")) throw new WorkerArtifactError("Native main_module is missing or not ESM"); + modules.sort((a,b) => a.path.localeCompare(b.path)); + const assets = staticAssets ? collectAssetFiles(staticAssets) : undefined; + const bundle: WorkerArtifactBundle = { version: 1, mainModule: main, modules, ...(observability ? {observability} : {}), ...(assets ? {assets} : {}) }; + assertArtifactContentLimit(bundle); + const stored = Buffer.from(JSON.stringify({ ...(observability ? {observability} : {}), version: 1, mainModule: main, modules: modules.map(module => ({ + path: module.path, contentBase64: Buffer.from(module.content, module.encoding === "base64" ? "base64" : "utf8").toString("base64"), contentType: module.contentType, + })), ...(assets ? {assets} : {}) })); + return { kind: "bundle", contentSha256: sha256(stored), sizeBytes: stored.length, upload: {bundle}, nativeMetadata: metadata }; +} + +export function validateNativeDeploymentMetadata( + artifact: LoadedWorkerArtifact, + settings: { compatibilityDate?: string; compatibilityFlags?: string[] }, + resources: Array<{type: string; bindingName: string}>, +): void { + const metadata = artifact.nativeMetadata; + if (!metadata) return; + if (metadata.compatibility_date !== settings.compatibilityDate || + JSON.stringify([...(metadata.compatibility_flags as string[] || [])].sort()) !== JSON.stringify([...(settings.compatibilityFlags || [])].sort())) { + throw new WorkerArtifactError("Wrangler bundle compatibility settings differ from deployment configuration; rebuild before publishing"); + } + const managed: Record = {d1: "d1_database", r2_bucket: "r2_bucket", kv_namespace: "kv_namespace"}; + for (const binding of (metadata.bindings || []) as UnknownRecord[]) { + if (!resources.some(resource => resource.bindingName === binding.name && resource.type === managed[String(binding.type)])) { + throw new WorkerArtifactError(`Native binding ${binding.name} is missing from xAPI resource declarations`); + } + } +} diff --git a/src/workers-plan-output.ts b/src/workers-plan-output.ts index bc64e42..205c239 100644 --- a/src/workers-plan-output.ts +++ b/src/workers-plan-output.ts @@ -10,6 +10,7 @@ const KIND_LABEL: Record = { budget: "Budget", resource: "Resource", secret: "Secret", + routing: "Routing", artifact: "Artifact", deployment: "Deployment", }; diff --git a/src/workers-plan.ts b/src/workers-plan.ts index b217601..117dff9 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -1,7 +1,7 @@ import { existsSync, lstatSync, statSync } from "node:fs"; import type { WorkersClientOptions } from "./workers-client.ts"; import * as workersClient from "./workers-client.ts"; -import { loadWorkerArtifact, WorkerArtifactError } from "./workers-artifact.ts"; +import { loadWorkerArtifactInput, validateNativeDeploymentMetadata, WorkerArtifactError } from "./workers-artifact.ts"; import { deploymentPrefix, currentMatchingDeployment } from "./workers-deployment-state.ts"; import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; import { @@ -24,6 +24,7 @@ export type WorkerPlanKind = | "budget" | "resource" | "secret" + | "routing" | "artifact" | "deployment"; @@ -84,8 +85,9 @@ const KIND_ORDER: Record = { budget: 1, resource: 2, secret: 3, - artifact: 4, - deployment: 5, + routing: 4, + artifact: 5, + deployment: 6, }; const REMOTE_RESOURCE_TYPE: Record = { @@ -354,11 +356,11 @@ function compareSecrets( return blocked; } -function localArtifact(project: LoadedWorkerProject): { +async function localArtifact(project: LoadedWorkerProject, environment: "preview" | "production"): Promise<{ sha256?: string; sizeBytes?: number; blocked?: string; -} { +}> { const path = resolveWorkerProjectPath( project, project.config.build.output, @@ -366,7 +368,21 @@ function localArtifact(project: LoadedWorkerProject): { ); if (!existsSync(path)) return {}; try { - const artifact = loadWorkerArtifact(path, project.config.build.main); + const artifact = await loadWorkerArtifactInput( + path, + project.config.build.main, + project.config.assets + ? { + ...project.config.assets, + directory: resolveWorkerProjectPath( + project, + project.config.assets.directory, + "assets.directory", + ), + } + : undefined, + ); + validateNativeDeploymentMetadata(artifact, readWranglerDeploymentSettings(project, environment), project.config.environments[environment].resources); return { sha256: artifact.contentSha256, sizeBytes: artifact.sizeBytes, @@ -405,7 +421,7 @@ function validatePlanInputs(project: LoadedWorkerProject): void { } } -function artifactAndDeployment( +async function artifactAndDeployment( actions: WorkerPlanAction[], project: LoadedWorkerProject, remote: UnknownRecord | undefined, @@ -415,8 +431,8 @@ function artifactAndDeployment( resources: UnknownRecord[], secrets: UnknownRecord[], environmentName: "preview" | "production", -): void { - const local = localArtifact(project); +): Promise { + const local = await localArtifact(project, environmentName); if (local.blocked) { add( actions, @@ -667,7 +683,23 @@ export async function createWorkerPlan( prerequisiteBlocked = compareSecrets(actions, desired.secrets, remoteSecrets) || prerequisiteBlocked; - artifactAndDeployment( + if (project.config.assets) { + const ready = remoteEnvironmentState?.webAppReady; + if (ready === true) { + add(actions, "NO_CHANGE", "routing", options.environment, "Web application has a dedicated hostname", undefined, { + routingMode: remoteEnvironmentState?.routingMode, + publicOrigin: remoteEnvironmentState?.publicOrigin, + }); + } else { + add(actions, "MANUAL", "routing", options.environment, remoteEnvironmentState + ? "Static assets can be tested through the dispatch path, but root-relative URLs and OAuth callbacks require a dedicated hostname" + : "Web hostname readiness will be checked after the Worker is created", undefined, { + routingMode: remoteEnvironmentState?.routingMode || "UNKNOWN", + publicBasePath: remoteEnvironmentState?.publicBasePath, + }); + } + } + await artifactAndDeployment( actions, project, remote, diff --git a/src/workers-project.ts b/src/workers-project.ts index e792e06..0e74cf1 100644 --- a/src/workers-project.ts +++ b/src/workers-project.ts @@ -104,6 +104,39 @@ const environmentSchema = z }) .strict(); +const staticAssetsSchema = z + .object({ + directory: relativeProjectPath, + binding: z + .string() + .regex( + /^[A-Z][A-Z0-9_]{0,63}$/, + "must start with A-Z and contain only A-Z, 0-9, and underscore", + ) + .optional(), + htmlHandling: z + .enum([ + "auto-trailing-slash", + "force-trailing-slash", + "drop-trailing-slash", + "none", + ]) + .optional(), + notFoundHandling: z + .enum(["none", "404-page", "single-page-application"]) + .optional(), + runWorkerFirst: z + .union([ + z.boolean(), + z + .array(z.string().min(1).max(500).regex(/^!?\//)) + .min(1) + .max(100), + ]) + .optional(), + }) + .strict(); + export const workerProjectConfigSchema = z .object({ $schema: z.literal(WORKER_PROJECT_SCHEMA_URL).optional(), @@ -130,6 +163,7 @@ export const workerProjectConfigSchema = z main: relativeProjectPath.optional(), }) .strict(), + assets: staticAssetsSchema.optional(), environments: z .object({ preview: environmentSchema, diff --git a/src/workers-promote.ts b/src/workers-promote.ts index bd3019b..2b1d753 100644 --- a/src/workers-promote.ts +++ b/src/workers-promote.ts @@ -29,7 +29,7 @@ export type PromotionCheckStatus = "NO_CHANGE" | "MANUAL" | "BLOCKED"; export interface WorkerPromotionCheck { status: PromotionCheckStatus; - kind: "budget" | "resource" | "secret"; + kind: "budget" | "resource" | "secret" | "routing"; key: string; message: string; command?: string; @@ -131,12 +131,31 @@ function productionChecks( remoteEnvironment: UnknownRecord, resources: UnknownRecord[], secrets: UnknownRecord[], + hasStaticAssets: boolean, ): { checks: WorkerPromotionCheck[]; dataRisk: string[] } { const checks: WorkerPromotionCheck[] = []; const dataRisk: string[] = [ "Promotion changes the Worker code Artifact only; it does not snapshot, copy, or roll back production data", ]; const currentBudget = amount(remoteEnvironment.dailyBudgetUsd); + if (hasStaticAssets) { + checks.push( + remoteEnvironment.webAppReady === true + ? { + status: "NO_CHANGE", + kind: "routing", + key: "production", + message: "Production web application has a dedicated hostname", + } + : { + status: "MANUAL", + kind: "routing", + key: "production", + message: + "Production is using path fallback; verify the application base path, root-relative URLs, and OAuth callbacks, or configure a dedicated hostname", + }, + ); + } if ( currentBudget === undefined || Math.abs(currentBudget - desired.dailyBudgetUsd) > 0.00005 @@ -278,8 +297,8 @@ function productionChecks( } checks.sort( (a, b) => - ({ budget: 0, resource: 1, secret: 2 })[a.kind] - - { budget: 0, resource: 1, secret: 2 }[b.kind] || + ({ routing: 0, budget: 1, resource: 2, secret: 3 })[a.kind] - + { routing: 0, budget: 1, resource: 2, secret: 3 }[b.kind] || a.key.localeCompare(b.key), ); return { checks, dataRisk: dataRisk.sort() }; @@ -380,6 +399,7 @@ export async function createWorkerPromotionPlan( production, resources, secrets, + Boolean(project.config.assets), ); const plan: WorkerPromotionPlan = { schemaVersion: 1, diff --git a/src/workers-push.ts b/src/workers-push.ts index 01ba25f..c13418e 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -12,7 +12,8 @@ import { createInterface } from "node:readline/promises"; import { HttpError, isRetryableRequestError } from "./client.ts"; import { type LoadedWorkerArtifact, - loadWorkerArtifact, + loadWorkerArtifactInput, + validateNativeDeploymentMetadata, WorkerArtifactError, type WorkerArtifactUploadRequest, } from "./workers-artifact.ts"; @@ -105,6 +106,7 @@ export interface WorkerPushResult { artifact: { id: string; contentSha256: string; sizeBytes: number }; deployment: { id: string; status: "ACTIVE"; idempotencyKey: string }; publicUrl: string; + routing?: { mode?: string; webAppReady: boolean; publicOrigin?: string; publicBasePath?: string }; health: { url: string; status: number; attempts: number }; commands: { logs: string; promote: string }; } @@ -244,14 +246,27 @@ async function terminalConfirm(): Promise { } } -function validateBundle(project: LoadedWorkerProject): LoadedWorkerArtifact { +async function validateBundle(project: LoadedWorkerProject): Promise { const path = resolveWorkerProjectPath( project, project.config.build.output, "build.output", ); try { - return loadWorkerArtifact(path, project.config.build.main); + return await loadWorkerArtifactInput( + path, + project.config.build.main, + project.config.assets + ? { + ...project.config.assets, + directory: resolveWorkerProjectPath( + project, + project.config.assets.directory, + "assets.directory", + ), + } + : undefined, + ); } catch (error) { if (error instanceof WorkerArtifactError) { throw new WorkerPushError(error.message); @@ -500,7 +515,7 @@ async function ensureArtifact( api: PushClient, options: WorkersClientOptions, workerId: string, - bundle: ReturnType, + bundle: Awaited>, ): Promise { const idempotencyKey = stableKey( "xapi-worker-artifact-v1", @@ -855,7 +870,8 @@ export async function pushWorkerProject( linkedProject.config.build.command, linkedProject.rootDir, ); - const bundle = validateBundle(linkedProject); + const bundle = await validateBundle(linkedProject); + validateNativeDeploymentMetadata(bundle, compatibility, linkedProject.config.environments.preview.resources); const artifact = await ensureArtifact( api, options.clientOptions, @@ -903,6 +919,7 @@ export async function pushWorkerProject( new Promise((resolve) => setTimeout(resolve, milliseconds))), ); const publicUrl = text(environmentOf(finalWorker, "preview").publicUrl)!; + const finalEnvironment = environmentOf(finalWorker, "preview"); return { schemaVersion: 1, status: "ACTIVE", @@ -924,6 +941,12 @@ export async function pushWorkerProject( idempotencyKey: deployed.idempotencyKey, }, publicUrl, + ...(linkedProject.config.assets ? { routing: { + mode: text(finalEnvironment.routingMode), + webAppReady: finalEnvironment.webAppReady === true, + publicOrigin: text(finalEnvironment.publicOrigin), + publicBasePath: text(finalEnvironment.publicBasePath), + } } : {}), health, commands: { logs: `xapi workers logs ${workerState.id} --env preview`, diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 51899c3..6eeecf5 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -88,6 +88,7 @@ const SUPPORTED_TOP_LEVEL = new Set([ "main", "compatibility_date", "compatibility_flags", + "assets", ]); const MANAGED_TOP_LEVEL = new Set([ "kv_namespaces", @@ -122,7 +123,6 @@ const IGNORED_TOP_LEVEL = new Set([ "upload_source_maps", "legacy_assets", "site", - "assets", "limits", "version_metadata", "tail_consumers", @@ -245,6 +245,40 @@ function bindingName( return value; } +function staticAssets( + preview: UnknownRecord, + production: UnknownRecord, + entries: WranglerCompatibilityEntry[], +): WorkerProjectConfig["assets"] | undefined { + const previewAssets = record(preview.assets); + const productionAssets = record(production.assets); + if (!previewAssets && !productionAssets) return undefined; + if (JSON.stringify(previewAssets) !== JSON.stringify(productionAssets)) { + compatibilityEntry(entries, "UNSUPPORTED", "assets", "Environment-specific static asset settings are not portable; use one shared assets configuration"); + return undefined; + } + const source = previewAssets || productionAssets!; + const candidate = { + directory: source.directory, + ...(source.binding !== undefined ? { binding: source.binding } : {}), + ...(source.html_handling !== undefined ? { htmlHandling: source.html_handling } : {}), + ...(source.not_found_handling !== undefined ? { notFoundHandling: source.not_found_handling } : {}), + ...(source.run_worker_first !== undefined ? { runWorkerFirst: source.run_worker_first } : {}), + }; + const parsed = workerProjectConfigSchema.shape.assets.safeParse(candidate); + if (!parsed.success) { + compatibilityEntry(entries, "UNSUPPORTED", "assets", `Static assets are invalid: ${parsed.error.issues[0]?.message || "invalid configuration"}`); + return undefined; + } + for (const key of Object.keys(source)) { + if (!["directory", "binding", "html_handling", "not_found_handling", "run_worker_first"].includes(key)) { + compatibilityEntry(entries, "UNSUPPORTED", `assets.${key}`, "This static asset setting is not supported by xAPI yet"); + } + } + compatibilityEntry(entries, "SUPPORTED", "assets", "Static asset directory, binding, and routing settings will be preserved"); + return parsed.data; +} + function physicalFields( item: UnknownRecord, retained: Set, @@ -617,6 +651,11 @@ export function importWranglerProject( preview: selectedConfig(wrangler, "preview"), production: selectedConfig(wrangler, "production"), }; + const assets = staticAssets( + desired.preview.config, + desired.production.config, + entries, + ); const previewResources = resourceList( desired.preview.config, desired.preview.prefix, @@ -697,6 +736,7 @@ export function importWranglerProject( }, wrangler: wranglerPath, build: { command: "npm run build", output: "dist/worker.mjs" }, + ...(assets ? { assets } : {}), environments: { preview: { dailyBudgetUsd: budget(options.previewDailyBudgetUsd, "preview"),