From c066307f2fb8a0b3a4746fcb25f67d3853b4374d Mon Sep 17 00:00:00 2001 From: GlacierLuo <1090490148@qq.com> Date: Tue, 25 Aug 2026 20:19:30 +0800 Subject: [PATCH 01/12] feat(skill): add Serper v7 workflows and mini-batch guidance --- README.md | 6 ++ skills/xapi/SKILL.md | 5 +- skills/xapi/guides/serper.md | 124 +++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 skills/xapi/guides/serper.md diff --git a/README.md b/README.md index 2b7bcdd..f8b7f7b 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,15 @@ xapi-to services --category Social --page-size 10 # filter and paginate xapi-to get twitter.tweet_detail # get action schema xapi-to get-batch twitter.tweet_detail crypto.token.price # get several schemas xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' # execute +xapi-to call serper.search --input '{"body":[{"q":"OpenAI"},{"q":"Cloudflare"}]}' # Serper mini-batch xapi-to call ai.text.chat.fast --input '{"messages":[{"role":"user","content":"Hi"}]}' --stream ``` +Direct `serper.*` actions use a nested `body`. Eleven current v7 actions accept +one request object or a mini-batch array; `serper.reviews` accepts only one +object. See the bundled [Serper guide](skills/xapi/guides/serper.md) for the +Action list, examples, and dynamic per-credit billing. + Search uses `--sort default|relevance|price`. `default` is the recommended order: it considers keyword coverage and match quality first, then favors stable built-in capabilities when matches are otherwise comparable. diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index 78e7b61..5c62cf0 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -373,7 +373,7 @@ npx xapi-to call web.search --input '{"q":"hello world"}' npx xapi-to call serper.search --input '{"body":{"q":"hello world"}}' ``` -This ensures correct types (strings, numbers, booleans) are preserved. +Read `guides/serper.md` before using direct `serper.*` actions; it covers all current v7 actions, mini-batches, Reviews, billing, and the `web.search.*` boundary. ## Code Generation (`--code`) @@ -450,7 +450,7 @@ Beyond built-in capabilities, xapi proxies **dozens** of third-party API service - **LinkedIn** (`linkedin`) — LinkedIn API (person profiles & career history, company pages, posts & comments, job search). For career history, see `guides/linkedin.md` first — the profile endpoint silently omits `experience`/`education` for ordinary profiles - **Weibo** (`weibo-app`) — Weibo API (user profiles, feeds, search, trending) - **5SIM SMS** (`5sim-sms`) — SMS verification (virtual numbers, activation codes) -- **Serper API** (`serper`) — Google Search API +- **Serper API** (`serper`) — 12 provider-native Google Search actions including web, images, news, maps, places, video, shopping, scholar, patents, autocomplete, Lens, and reviews. Eleven support mini-batch; Reviews does not. Read `guides/serper.md` before calling them - **OpenRouter API** (`openrouter`) — Multi-model AI gateway (chat, embeddings, audio transcription/speech, video) The full catalog also spans many other categories — crypto/on-chain data, CEX market data, stocks & macro, social platforms, news, weather, and more. Discover them with `search` / `services`. @@ -482,6 +482,7 @@ When the user's task involves these workflows, read the corresponding guide file - **`guides/xiaohongshu.md`** — 小红书 (Xiaohongshu): user profiles, notes, comments, search, topics, products, creator inspiration - **`guides/weibo.md`** — Weibo (微博): hot search, content search, user profiles, post details, comments, reposts, media - **`guides/google_search.md`** — Google Search: web, realtime, news, image, video, scholar, maps, places, shopping +- **`guides/serper.md`** — direct Serper v7 API: 12 provider-native actions, object-or-array mini-batches, Reviews pagination and batch exception, Lens, dynamic per-credit billing, and the current Webpage service boundary - **`guides/crypto.md`** — Crypto (加密货币): on-chain token price/overview/holders/security/OHLCV, wallet analytics, DEX pairs, CEX spot prices by symbol, news — covers contract-address vs symbol addressing and multi-chain - **`guides/ai.md`** — AI (人工智能): synchronous or SSE-streamed text, embeddings, asynchronous image/video generation with `task wait`, text-to-speech, and speech-to-text - **`guides/ai_gateway.md`** — xAPI AI Gateway: Claude Code and Anthropic/OpenAI SDK setup, model discovery, routing strategies, streaming, fallback, routing/billing headers, direct media endpoints, and known limitations diff --git a/skills/xapi/guides/serper.md b/skills/xapi/guides/serper.md new file mode 100644 index 0000000..e1a3b4e --- /dev/null +++ b/skills/xapi/guides/serper.md @@ -0,0 +1,124 @@ +# Serper Guide + +Use the direct `serper.*` API actions when the task needs provider-native +Google results, several searches in one mini-batch, or Serper surfaces that the +built-in `web.search.*` capabilities do not expose. For a simple single search +with a normalized xAPI response, prefer `web.search.*` and read +`google_search.md` instead. + +The current `serper` service exposes 12 v7 actions. They are third-party API +actions, so parameters go inside `body`: + +```bash +npx xapi-to get serper.search +npx xapi-to call serper.search --input '{"body":{"q":"OpenAI","gl":"us","hl":"en"}}' +``` + +Run `get` before relying on optional parameters or response fields. Serper +responses are passed through in provider-native form and can gain fields that +are not declared in the xAPI output schema. + +## Mini-batch + +Eleven actions accept either one request object or an array of request objects +in `body`. The response is respectively one result object or an array of result +objects in request order: + +```bash +npx xapi-to call serper.search --input \ + '{"body":[{"q":"OpenAI","gl":"us","hl":"en"},{"q":"Cloudflare","gl":"us","hl":"en"}]}' +``` + +Each array member has the same shape as a single request. Do not wrap the +members in `queries`, and do not confuse this with `xapi-to get-batch`, which +retrieves several Action schemas without executing them. + +`serper.reviews` is the only current `serper.*` action that does not support +mini-batch. Send exactly one object in its `body`. + +## Billing + +All 12 actions use dynamic xAPI billing at **$0.002 per Serper credit**. For a +single request, the charge is `response.credits * $0.002`; for a mini-batch it +is `sum(response[*].credits) * $0.002`. + +The `cost: 0` placeholder shown in discovery output does not mean the call is +free; dynamic prices are not comparable as a fixed per-call price. Inspect the +Action's `meta.description` and `meta.pricing`, and keep returned `credits` when +auditing usage. + +## Current Actions + +| Action | Use it for | Primary input | +|---|---|---| +| `serper.search` | General Google web results | `q` | +| `serper.images` | Google Images; current schema accepts `num` 10 or 100 | `q` | +| `serper.news` | Google News results | `q` | +| `serper.videos` | Google video results | `q` | +| `serper.shopping` | Product and shopping results | `q` | +| `serper.scholar` | Academic publications and citations | `q` | +| `serper.patents` | Patent search | `q` | +| `serper.autocomplete` | Suggestions for a partial query | `q` | +| `serper.places` | Local businesses and place search | `q` | +| `serper.maps` | Map search or lookup by Google Place ID/CID | `q`, `placeId`, or `cid` | +| `serper.lens` | Reverse image search from a public image URL | `url` | +| `serper.reviews` | Place reviews and cursor pagination | `placeId`, `cid`, or `fid` | + +The common search-family controls are `gl`, `hl`, `location`, `page`, `num`, +`tbs`, and `autocorrect`, but not every action exposes every control. Use the +current `get` schema instead of copying parameters between actions. + +## Focused Examples + +### News with a Google time filter + +```bash +npx xapi-to call serper.news --input \ + '{"body":{"q":"AI regulation","gl":"us","hl":"en","tbs":"qdr:d"}}' +``` + +### Maps by coordinates + +```bash +npx xapi-to call serper.maps --input \ + '{"body":{"q":"coffee","ll":"@40.7455096,-74.0083012,14z","hl":"en"}}' +``` + +Use `placeId` or `cid` instead of `q` when resolving a known Google place. + +### Google Lens + +```bash +npx xapi-to call serper.lens --input \ + '{"body":{"url":"https://example.com/public-image.jpg","gl":"us","hl":"en"}}' +``` + +The image must be reachable through a public URL; a local filesystem path is +not a valid Lens input. + +### Reviews and pagination + +```bash +# First page; body must be an object, not an array +npx xapi-to call serper.reviews --input \ + '{"body":{"placeId":"ChIJ...","sortBy":"newest","gl":"us","hl":"en"}}' + +# Continue with the provider's cursor +npx xapi-to call serper.reviews --input \ + '{"body":{"placeId":"ChIJ...","nextPageToken":"","sortBy":"newest","gl":"us","hl":"en"}}' +``` + +Current `sortBy` values are `mostRelevant`, `newest`, `highestRating`, and +`lowestRating`. + +## Service Boundary + +Serper's upstream product also advertises webpage extraction, but the current +xAPI service directory exposes only the 12 `serper.*` actions above. Do not +invent or call `serper.webpage`. Search the live registry first; if a Webpage +Action is added later, use its own discovered Action ID and schema because the +upstream scraper is a separate surface from Google search. + +For provider details that are not exposed by `xapi-to get`, consult the current +official Serper documentation at . xAPI's `body` wrapper, +Action IDs, and billing metadata remain authoritative for calls through xAPI. From 23e29b8304e860c4f7da28b85cde337b3833eefa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:20:33 +0000 Subject: [PATCH 02/12] chore(main): release 0.1.20 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 170cd46..7f26ba9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.19" + ".": "0.1.20" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eaa2c9..309c356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.1.20](https://github.com/xapi-labs/xapi-cli/compare/v0.1.19...v0.1.20) (2026-08-25) + + +### Features + +* **sandbox:** add managed sandbox CLI workflows ([bb31e74](https://github.com/xapi-labs/xapi-cli/commit/bb31e74e11a231f50ba2fdd56c4ed67bfcf98f88)) +* **search:** --all-versions 开关(搜索含非默认但在跑的大版本) ([d8b2e9b](https://github.com/xapi-labs/xapi-cli/commit/d8b2e9b0f23c220244466792288a0d4982c80cd3)) +* **skill:** add Serper v7 workflows and mini-batch guidance ([c066307](https://github.com/xapi-labs/xapi-cli/commit/c066307f2fb8a0b3a4746fcb25f67d3853b4374d)) + ## [0.1.19](https://github.com/xapi-labs/xapi-cli/compare/v0.1.18...v0.1.19) (2026-08-10) diff --git a/package.json b/package.json index 96de17f..25eacd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xapi-to", - "version": "0.1.19", + "version": "0.1.20", "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs", "type": "module", "bin": { From be18c7e498697306974b6b973cfa38430db7fff7 Mon Sep 17 00:00:00 2001 From: Trynax Date: Thu, 27 Aug 2026 11:12:35 +0100 Subject: [PATCH 03/12] fix(sandbox): enforce hard wait deadlines during polling --- src/client.ts | 30 +++++++++++++++---- src/sandbox-client.ts | 51 ++++++++++++++++++++++---------- src/tests/client.test.ts | 19 ++++++++++++ src/tests/sandbox-client.test.ts | 39 ++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/src/client.ts b/src/client.ts index d5b0cda..b45bde8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -100,8 +100,28 @@ function parseRetryAfterMs(res: Response): number | undefined { return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +function abortError(signal?: AbortSignal | null): Error { + const reason = signal?.reason; + return reason instanceof Error + ? reason + : new DOMException('The operation was aborted', 'AbortError'); +} + +function sleep(ms: number, signal?: AbortSignal | null): Promise { + if (signal?.aborted) return Promise.reject(abortError(signal)); + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const onAbort = () => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(abortError(signal)); + }; + timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); } export async function request( @@ -138,7 +158,7 @@ export async function request( if (isRetryableStatus(res.status) && attempt < retries) { await res.text().catch(() => ''); // drain body so the socket can be reused clearTimeout(timer); - await sleep(backoffDelayMs(attempt, retryAfterMs)); + await sleep(backoffDelayMs(attempt, retryAfterMs), callerSignal); attempt++; continue; } @@ -174,7 +194,7 @@ export async function request( if (timedOut) { const timeoutError = new RequestTimeoutError(timeoutMs); if (attempt < retries) { - await sleep(backoffDelayMs(attempt)); + await sleep(backoffDelayMs(attempt), callerSignal); attempt++; continue; } @@ -182,7 +202,7 @@ export async function request( } if (isRetryableNetworkError(e) && attempt < retries) { clearTimeout(timer); - await sleep(backoffDelayMs(attempt)); + await sleep(backoffDelayMs(attempt), callerSignal); attempt++; continue; } diff --git a/src/sandbox-client.ts b/src/sandbox-client.ts index 6b9fc9b..0c2fd36 100644 --- a/src/sandbox-client.ts +++ b/src/sandbox-client.ts @@ -263,24 +263,43 @@ export async function sandboxWait( signal?: AbortSignal, ): Promise { const deadline = Date.now() + timeoutMs; + const deadlineController = new AbortController(); + const abortFromCaller = () => deadlineController.abort(); + const deadlineTimer = setTimeout(() => deadlineController.abort(), Math.max(0, timeoutMs)); + if (signal?.aborted) deadlineController.abort(); + else signal?.addEventListener('abort', abortFromCaller, { once: true }); let last: SandboxDetail | undefined; - while (Date.now() < deadline) { - if (signal?.aborted) throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`); - last = await sandboxGet(opts, id, signal); - const state = String(last.observedState || ''); - if (wanted.includes(state)) return last; - if (['FAILED', 'TERMINATED'].includes(state) && !wanted.includes(state)) { - throw new Error(`sandbox ${id} entered ${state} while waiting for ${wanted.join(' or ')}`); + try { + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`); + try { + last = await sandboxGet(opts, id, deadlineController.signal); + } catch (error) { + if (signal?.aborted) { + throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`); + } + if (Date.now() >= deadline) break; + throw error; + } + if (Date.now() >= deadline) break; + const state = String(last.observedState || ''); + if (wanted.includes(state)) return last; + if (['FAILED', 'TERMINATED'].includes(state) && !wanted.includes(state)) { + throw new Error(`sandbox ${id} entered ${state} while waiting for ${wanted.join(' or ')}`); + } + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', done); + resolve(); + }; + const timer = setTimeout(done, Math.min(intervalMs, Math.max(0, deadline - Date.now()))); + signal?.addEventListener('abort', done, { once: true }); + }); } - await new Promise((resolve) => { - const done = () => { - clearTimeout(timer); - signal?.removeEventListener('abort', done); - resolve(); - }; - const timer = setTimeout(done, Math.min(intervalMs, Math.max(0, deadline - Date.now()))); - signal?.addEventListener('abort', done, { once: true }); - }); + } finally { + clearTimeout(deadlineTimer); + signal?.removeEventListener('abort', abortFromCaller); } throw new Error( `sandbox ${id} did not enter ${wanted.join(' or ')} within ${timeoutMs}ms` + diff --git a/src/tests/client.test.ts b/src/tests/client.test.ts index f6c133b..f5b6696 100644 --- a/src/tests/client.test.ts +++ b/src/tests/client.test.ts @@ -59,6 +59,25 @@ describe('client.request', () => { expect(calls).toBe(1); }); + it('interrupts retry backoff when the caller aborts', async () => { + process.env.XAPI_RETRY_BASE_MS = '100'; + let calls = 0; + fetchSpy = mockFetch(async () => { + calls++; + return new Response('busy', { status: 503 }); + }); + const controller = new AbortController(); + const pending = request( + 'https://action.xapi.to/x', + { method: 'GET', signal: controller.signal }, + 5_000, + 2, + ); + controller.abort(); + await expect(pending).rejects.toThrow(/aborted/i); + expect(calls).toBe(1); + }); + it('does NOT retry by default (fail-safe for non-idempotent writes)', async () => { let calls = 0; fetchSpy = mockFetch(async () => { diff --git a/src/tests/sandbox-client.test.ts b/src/tests/sandbox-client.test.ts index d3eb1e1..fdc0324 100644 --- a/src/tests/sandbox-client.test.ts +++ b/src/tests/sandbox-client.test.ts @@ -155,6 +155,45 @@ describe('sandbox client', () => { expect(calls).toBe(3); }); + it('aborts an in-flight state read when the wait deadline expires', async () => { + let calls = 0; + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(((_url: any, init: any) => { + calls++; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + { once: true }, + ); + }); + }) as any); + await expect(client.sandboxWait( + { sandboxHost: 'sandbox.test.xapi.to', apiKey: 'sk-test' }, + 'box-1', + ['RUNNING'], + 20, + 1, + )).rejects.toThrow(/within 20ms/); + expect(calls).toBe(1); + }); + + it('does not accept a desired state returned after the wait deadline', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return new Response(JSON.stringify({ id: 'box-1', observedState: 'RUNNING' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as any); + await expect(client.sandboxWait( + { sandboxHost: 'sandbox.test.xapi.to', apiKey: 'sk-test' }, + 'box-1', + ['RUNNING'], + 5, + 1, + )).rejects.toThrow(/within 5ms/); + }); + it('interrupts a state wait promptly so callers can clean up', async () => { fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ id: 'box-1', observedState: 'PROVISIONING', From 9f89e8077260c97e961c323d83465c61e557c458 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:27:25 +0000 Subject: [PATCH 04/12] chore(main): release 0.1.21 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7f26ba9..5520dc3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.20" + ".": "0.1.21" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 309c356..16d8205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.1.21](https://github.com/xapi-labs/xapi-cli/compare/v0.1.20...v0.1.21) (2026-08-28) + + +### Bug Fixes + +* **sandbox:** enforce hard wait deadlines during polling ([794ca16](https://github.com/xapi-labs/xapi-cli/commit/794ca16455e4bc6cbc1dccdec006652e5a16d437)) +* **sandbox:** enforce hard wait deadlines during polling ([be18c7e](https://github.com/xapi-labs/xapi-cli/commit/be18c7e498697306974b6b973cfa38430db7fff7)) + ## [0.1.20](https://github.com/xapi-labs/xapi-cli/compare/v0.1.19...v0.1.20) (2026-08-25) diff --git a/package.json b/package.json index 25eacd2..4d24a4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xapi-to", - "version": "0.1.20", + "version": "0.1.21", "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs", "type": "module", "bin": { From 86e6828c411df85acbb6ad471951d38b31693590 Mon Sep 17 00:00:00 2001 From: Trynax Date: Mon, 7 Sep 2026 15:46:09 +0100 Subject: [PATCH 05/12] fix(oauth): enforce hard polling deadlines Enforce the OAuth binding timeout across polling intervals, in-flight requests, and retry backoff. Surface permanent request failures immediately and add regression coverage for deadline behavior. --- src/client.ts | 8 ++- src/commands/oauth.ts | 122 ++++++++++++++++++++++++++++------ src/tests/oauth.test.ts | 140 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 93599a3..6422956 100644 --- a/src/client.ts +++ b/src/client.ts @@ -683,7 +683,11 @@ export async function initiateOAuth( ); } -export async function listOAuthBindings(jwtToken: string, apiHost: string) { +export async function listOAuthBindings( + jwtToken: string, + apiHost: string, + signal?: AbortSignal, +) { return request>( `${scheme(apiHost)}://${apiHost}/api/oauth/bindings`, - { method: 'GET', headers: jwtHeaders(jwtToken) }, + { method: 'GET', headers: jwtHeaders(jwtToken), signal }, DEFAULT_TIMEOUT_MS, IDEMPOTENT_RETRIES, ); diff --git a/src/commands/oauth.ts b/src/commands/oauth.ts index 411c094..71fd0ce 100644 --- a/src/commands/oauth.ts +++ b/src/commands/oauth.ts @@ -24,6 +24,7 @@ import { initiateOAuth, listOAuthBindings, deleteOAuthBinding, + isRetryableRequestError, } from '../client.ts'; import type { OAuthProvider, ScopeDefinition } from '../client.ts'; import { output, err } from '../format.ts'; @@ -67,6 +68,69 @@ function bindingChangedAfter( return changedAt >= startedAtMs; } +const POLL_DEADLINE = Symbol('oauth poll deadline'); + +/** Wait for an interval without sleeping past the polling deadline. */ +function waitForPollInterval(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(false); + return; + } + + let timer: ReturnType | undefined; + const cleanup = () => signal.removeEventListener('abort', onAbort); + const onAbort = () => { + if (timer !== undefined) clearTimeout(timer); + cleanup(); + resolve(false); + }; + + timer = setTimeout(() => { + cleanup(); + resolve(true); + }, Math.max(0, ms)); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} + +/** Resolve when an operation finishes or when the poll deadline aborts it. */ +function resolveOnPollAbort( + operation: Promise, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => signal.removeEventListener('abort', onAbort); + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(POLL_DEADLINE); + }; + const resolveOperation = (value: T) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const rejectOperation = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + operation.then(resolveOperation, rejectOperation); + if (signal.aborted) { + onAbort(); + return; + } + }); +} + export async function pollForBinding( apiKeyId: string, providerId: string, @@ -79,28 +143,50 @@ export async function pollForBinding( const deadline = Date.now() + timeoutMs; const isTTY = process.stdout.isTTY; const startedAtMs = startedAt.getTime() - 5000; + const controller = new AbortController(); + const deadlineTimer = setTimeout( + () => controller.abort(), + Math.max(0, deadline - Date.now()), + ); - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, intervalMs)); + try { + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + const intervalElapsed = await waitForPollInterval( + Math.min(Math.max(0, intervalMs), remaining), + controller.signal, + ); + if (!intervalElapsed || Date.now() >= deadline || controller.signal.aborted) break; - try { - const bindings = await listOAuthBindings(jwtToken, XAPI_API_HOST); - const match = Array.isArray(bindings) - ? bindings.find((b) => - b.apiKeyId === apiKeyId && - b.providerId === providerId && - bindingChangedAfter(b, startedAtMs, existingBindingIds) - ) - : null; - if (match) return match; - } catch { - // transient error — keep polling - } + try { + const bindings = await resolveOnPollAbort( + listOAuthBindings(jwtToken, XAPI_API_HOST, controller.signal), + controller.signal, + ); + if (bindings === POLL_DEADLINE || Date.now() >= deadline) break; + + const match = Array.isArray(bindings) + ? bindings.find((b) => + b.apiKeyId === apiKeyId && + b.providerId === providerId && + bindingChangedAfter(b, startedAtMs, existingBindingIds) + ) + : null; + if (match) return match; + } catch (e) { + if (controller.signal.aborted || Date.now() >= deadline) break; + if (!isRetryableRequestError(e)) throw e; + // Transient errors are retried until the deadline. + } - if (isTTY) { - const remaining = Math.ceil((deadline - Date.now()) / 1000); - process.stdout.write(`\r Waiting for authorization... (${remaining}s remaining) `); + if (isTTY) { + const remaining = Math.ceil((deadline - Date.now()) / 1000); + process.stdout.write(`\r Waiting for authorization... (${remaining}s remaining) `); + } } + } finally { + clearTimeout(deadlineTimer); + controller.abort(); } if (process.stdout.isTTY) process.stdout.write('\n'); diff --git a/src/tests/oauth.test.ts b/src/tests/oauth.test.ts index a6504f1..71ec7ce 100644 --- a/src/tests/oauth.test.ts +++ b/src/tests/oauth.test.ts @@ -212,6 +212,146 @@ describe('oauth commands', () => { }); describe('pollForBinding', () => { + it('does not start a poll after the deadline', async () => { + const listBindingsSpy = spyOn(client, 'listOAuthBindings').mockResolvedValue([] as any); + + try { + const binding = await pollForBinding( + MOCK_KEY_ID, + 'prov-1', + MOCK_JWT, + new Date(), + new Set(), + 0, + 10, + ); + + expect(binding).toBeNull(); + expect(listBindingsSpy).not.toHaveBeenCalled(); + } finally { + listBindingsSpy.mockRestore(); + } + }); + + it('cancels an in-flight poll when the deadline expires', async () => { + let aborted = false; + const listBindingsSpy = spyOn(client, 'listOAuthBindings').mockImplementation( + async (_jwtToken, _apiHost, signal) => { + await new Promise((_resolve, reject) => { + if (signal?.aborted) { + aborted = true; + reject(new DOMException('aborted', 'AbortError')); + return; + } + signal?.addEventListener('abort', () => { + aborted = true; + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); + return [] as any; + }, + ); + + try { + const binding = await pollForBinding( + MOCK_KEY_ID, + 'prov-1', + MOCK_JWT, + new Date(), + new Set(), + 20, + 1, + ); + + expect(binding).toBeNull(); + expect(aborted).toBe(true); + expect(listBindingsSpy).toHaveBeenCalledWith( + MOCK_JWT, + expect.any(String), + expect.any(AbortSignal), + ); + } finally { + listBindingsSpy.mockRestore(); + } + }); + + it('does not accept a binding returned after the deadline', async () => { + const listBindingsSpy = spyOn(client, 'listOAuthBindings').mockImplementation( + async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return [mockBindings[0]] as any; + }, + ); + + try { + const binding = await pollForBinding( + MOCK_KEY_ID, + 'prov-1', + MOCK_JWT, + new Date(), + new Set(), + 10, + 1, + ); + + expect(binding).toBeNull(); + expect(listBindingsSpy).toHaveBeenCalledTimes(1); + } finally { + listBindingsSpy.mockRestore(); + } + }); + + it('surfaces non-retryable polling errors immediately', async () => { + const listBindingsSpy = spyOn(client, 'listOAuthBindings') + .mockRejectedValue(new client.HttpError(401, 'unauthorized')); + + try { + await expect( + pollForBinding( + MOCK_KEY_ID, + 'prov-1', + MOCK_JWT, + new Date(), + new Set(), + 100, + 1, + ), + ).rejects.toThrow('HTTP 401'); + expect(listBindingsSpy).toHaveBeenCalledTimes(1); + } finally { + listBindingsSpy.mockRestore(); + } + }); + + it('continues polling after transient errors', async () => { + const listBindingsSpy = spyOn(client, 'listOAuthBindings') + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce([ + { + ...mockBindings[0], + createdAt: '2026-06-03T00:00:01.000Z', + updatedAt: '2026-06-03T00:00:01.000Z', + }, + ] as any); + + try { + const binding = await pollForBinding( + MOCK_KEY_ID, + 'prov-1', + MOCK_JWT, + new Date('2026-06-03T00:00:00.000Z'), + new Set(), + 100, + 1, + ); + + expect(binding?.id).toBe('bind-uuid-1'); + expect(listBindingsSpy).toHaveBeenCalledTimes(2); + } finally { + listBindingsSpy.mockRestore(); + } + }); + it('ignores existing bindings from before the current authorization', async () => { const listBindingsSpy = spyOn(client, 'listOAuthBindings') .mockResolvedValueOnce([ From 49c974b82d42decb6fb55c7034f8cd562b2c9d03 Mon Sep 17 00:00:00 2001 From: GlacierLuo <1090490148@qq.com> Date: Thu, 10 Sep 2026 18:26:15 +0800 Subject: [PATCH 06/12] feat(provider): manage per-user service rate limits --- README.md | 6 ++ skills/xapi/guides/provider.md | 30 ++++++++++ src/commands/provider.ts | 50 ++++++++++++++-- src/tests/provider.test.ts | 80 ++++++++++++++++++++++++++ src/tests/skill-provider-guide.test.ts | 13 +++++ 5 files changed, 173 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0e6fcff..a42e212 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,8 @@ management without a JWT exchange: xapi-to provider list xapi-to provider create --file ./service.json xapi-to provider update --about-file ./ABOUT.md --website https://example.com +xapi-to provider update --rate-limit-requests 100 --rate-limit-period-seconds 60 +xapi-to provider update --clear-rate-limit xapi-to provider versions xapi-to provider revision start 1 xapi-to provider version update --file ./contract.json @@ -359,6 +361,10 @@ xapi-to provider metrics --days 7 xapi-to provider events --after '' ``` +Service rate limits are optional and supported only for proxied services. Both +numeric flags are required when setting a limit; the quota is shared by all API +keys belonging to the same user for that service. + Service usage tutorials are Skill packages. Scaffold one from the serving contract, submit it for review, wait for publication, then link it: diff --git a/skills/xapi/guides/provider.md b/skills/xapi/guides/provider.md index 22d6e15..e5cc5ac 100644 --- a/skills/xapi/guides/provider.md +++ b/skills/xapi/guides/provider.md @@ -55,6 +55,36 @@ Use `--clear-about` or `--clear-website` to clear a value. Provider metadata updates cannot modify the version contract or upstream credentials; use the version command for those fields. +## Configure a service request limit + +Rate limits are optional service settings. Configure both values together when +creating a service or updating an existing one: + +```bash +npx xapi-to provider create --file ./service.json \ + --rate-limit-requests 100 \ + --rate-limit-period-seconds 60 + +npx xapi-to provider update \ + --rate-limit-requests 100 \ + --rate-limit-period-seconds 60 +``` + +`requests` accepts 1 through 1,000,000 and `periodSeconds` accepts 1 through +86,400. The backend applies one quota to each User x Service pair, so all API keys +owned by the same user share that service quota. This setting is supported only +for `PROXY` services; the backend rejects a non-null limit for `DIRECT` services. + +Disable the limit explicitly with: + +```bash +npx xapi-to provider update --clear-rate-limit +``` + +This sends `rateLimitConfig: null`. Omitting the rate-limit flags during an +update leaves the existing setting unchanged. A raw `rateLimitConfig` can also +be included in `service.json`; explicit CLI rate-limit flags override that field. + ## Edit and publish a revision ```bash diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 1244a9a..5469b97 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -18,8 +18,8 @@ export const PROVIDER_HELP = `xapi-to provider - Manage provider services and th USAGE xapi-to provider list xapi-to provider get [--version ] - xapi-to provider create --file - xapi-to provider update [metadata flags] + xapi-to provider create --file [rate-limit flags] + xapi-to provider update [metadata/rate-limit flags] xapi-to provider versions xapi-to provider version update --file [--replace] xapi-to provider major create @@ -52,6 +52,14 @@ METADATA FLAGS --logo-url Service logo URL --category Marketplace category +SERVICE RATE-LIMIT FLAGS + --rate-limit-requests Allowed requests per period (1-1000000) + --rate-limit-period-seconds Period length in seconds (1-86400) + --clear-rate-limit Disable the service rate limit + +Set both numeric flags together. Limits apply only to PROXY services and use +one shared quota for each user and service, including all API keys of that user. + SCOPES list/get/versions/review/diff/skill context: service:read create: service:create @@ -96,6 +104,32 @@ function boolFlag(flags: Record, name: string): boolean { return ['true', '1', 'yes'].includes((flags[name] || '').toLowerCase()); } +function applyRateLimitFlags( + body: Record, + flags: Record, +): Record { + const requests = flags['rate-limit-requests']; + const periodSeconds = flags['rate-limit-period-seconds']; + const clear = boolFlag(flags, 'clear-rate-limit'); + + if (clear && (requests !== undefined || periodSeconds !== undefined)) { + err('--clear-rate-limit cannot be combined with --rate-limit-requests or --rate-limit-period-seconds'); + } + if ((requests === undefined) !== (periodSeconds === undefined)) { + err('--rate-limit-requests and --rate-limit-period-seconds must be provided together'); + } + + if (clear) { + body.rateLimitConfig = null; + } else if (requests !== undefined) { + body.rateLimitConfig = { + requests: positiveInt(requests, '--rate-limit-requests', 1_000_000), + periodSeconds: positiveInt(periodSeconds, '--rate-limit-period-seconds', 86_400), + }; + } + return body; +} + async function readText(path: string, flagName: string): Promise { if (path === 'true') err(`${flagName} requires a path or - for stdin`); if (path === '-') { @@ -160,8 +194,9 @@ async function metadataBody(flags: Record): Promise) { break; } - case 'create': + case 'create': { + const body = await readJsonObject(required(flags.file, 'xapi-to provider create --file ')); + applyRateLimitFlags(body, flags); result = await apiKeyApiRequest(XAPI_API_HOST, apiKey, `${BASE}/services`, { method: 'POST', - body: await readJsonObject(required(flags.file, 'xapi-to provider create --file ')), + body, }); break; + } case 'update': { - const id = required(rest[0], 'xapi-to provider update [metadata flags]'); + const id = required(rest[0], 'xapi-to provider update [metadata/rate-limit flags]'); result = await apiKeyApiRequest(XAPI_API_HOST, apiKey, servicePath(id), { method: 'PATCH', body: await metadataBody(flags), diff --git a/src/tests/provider.test.ts b/src/tests/provider.test.ts index cccaed5..fcaba0d 100644 --- a/src/tests/provider.test.ts +++ b/src/tests/provider.test.ts @@ -54,6 +54,59 @@ describe('provider command', () => { ); }); + it('creates a service with an explicitly configured per-user rate limit', async () => { + const dir = await mkdtemp(join(tmpdir(), 'xapi-provider-create-test-')); + temporary.push(dir); + const service = join(dir, 'service.json'); + await writeFile(service, JSON.stringify({ name: 'Weather', accessMode: 'PROXY' })); + + await provider(['create'], { + file: service, + 'rate-limit-requests': '100', + 'rate-limit-period-seconds': '60', + }); + + expect(requestSpy).toHaveBeenCalledWith( + 'api.xapi.to', + 'sk-provider', + '/api/api-services/agent/services', + { + method: 'POST', + body: { + name: 'Weather', + accessMode: 'PROXY', + rateLimitConfig: { requests: 100, periodSeconds: 60 }, + }, + }, + ); + }); + + it('updates or clears a service rate limit without metadata', async () => { + await provider(['update', 'service-1'], { + 'rate-limit-requests': '250', + 'rate-limit-period-seconds': '3600', + }); + + expect(requestSpy).toHaveBeenLastCalledWith( + 'api.xapi.to', + 'sk-provider', + '/api/api-services/agent/services/service-1', + { + method: 'PATCH', + body: { rateLimitConfig: { requests: 250, periodSeconds: 3600 } }, + }, + ); + + await provider(['update', 'service-1'], { 'clear-rate-limit': 'true' }); + + expect(requestSpy).toHaveBeenLastCalledWith( + 'api.xapi.to', + 'sk-provider', + '/api/api-services/agent/services/service-1', + { method: 'PATCH', body: { rateLimitConfig: null } }, + ); + }); + it('publishes a revision with changelog content and no automatic retry', async () => { await provider(['publish', 'service-1', 'revision-1'], { changelog: 'Added provider metrics', @@ -115,6 +168,10 @@ describe('provider command', () => { [['skill', 'scaffold', 'service-1'], { output: 'true' }], [['skill', 'fingerprint', 'service-1'], { 'skill-version-id': 'true' }], [['delete', 'service-1'], { confirm: 'true' }], + [['update', 'service-1'], { + 'rate-limit-requests': 'true', + 'rate-limit-period-seconds': '60', + }], ] as Array<[string[], Record]>) { requestSpy.mockClear(); await expect(provider(args, flags)).rejects.toThrow('err called'); @@ -132,4 +189,27 @@ describe('provider command', () => { '--clear-about cannot be combined with --about or --about-file', ); }); + + it('rejects incomplete, conflicting, or out-of-range rate-limit flags before network I/O', async () => { + for (const flags of [ + { 'rate-limit-requests': '100' }, + { + 'rate-limit-requests': '100', + 'rate-limit-period-seconds': '60', + 'clear-rate-limit': 'true', + }, + { + 'rate-limit-requests': '1000001', + 'rate-limit-period-seconds': '60', + }, + { + 'rate-limit-requests': '100', + 'rate-limit-period-seconds': '86401', + }, + ] as Array>) { + requestSpy.mockClear(); + await expect(provider(['update', 'service-1'], flags)).rejects.toThrow('err called'); + expect(requestSpy).not.toHaveBeenCalled(); + } + }); }); diff --git a/src/tests/skill-provider-guide.test.ts b/src/tests/skill-provider-guide.test.ts index 5243b04..5379864 100644 --- a/src/tests/skill-provider-guide.test.ts +++ b/src/tests/skill-provider-guide.test.ts @@ -53,4 +53,17 @@ describe('bundled provider guide', () => { expect(guide).toContain(command); } }); + + it('documents service rate-limit management and quota identity', () => { + for (const text of [ + '--rate-limit-requests', + '--rate-limit-period-seconds', + '--clear-rate-limit', + 'User x Service', + 'PROXY', + 'all API keys', + ]) { + expect(guide).toContain(text); + } + }); }); From 86de0d11f89a4a9d52fa1d407541150d71addefe Mon Sep 17 00:00:00 2001 From: GlacierLuo <1090490148@qq.com> Date: Thu, 10 Sep 2026 20:20:59 +0800 Subject: [PATCH 07/12] feat(skill): add domain and Web3 service guides --- README.md | 3 +- skills/xapi/SKILL.md | 10 +- skills/xapi/guides/binance_web3.md | 191 ++++++++++++++++++++ skills/xapi/guides/blockpi.md | 106 +++++++++++ skills/xapi/guides/domains.md | 131 ++++++++++++++ src/tests/skill-live-services-guide.test.ts | 49 +++++ 6 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 skills/xapi/guides/binance_web3.md create mode 100644 skills/xapi/guides/blockpi.md create mode 100644 skills/xapi/guides/domains.md create mode 100644 src/tests/skill-live-services-guide.test.ts diff --git a/README.md b/README.md index a42e212..1ee80fc 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ npx skills add xapi-labs/xapi-cli ``` This installs the bundled [`xapi` skill](skills/xapi), which teaches the agent -how to call social, search, crypto, and AI data through this CLI. Then just ask +how to call social, search, domains/DNS, crypto, BlockPI RPC, Binance Web3, and +AI services through this CLI. Then just ask — "what's the price of BTC" — and it takes it from there. Set up a key first; see [Quick Start](#quick-start). diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index ee0e070..8362ff4 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -1,6 +1,6 @@ --- name: xapi -description: Access real-time external data and managed cloud sandboxes via the xapi CLI — Twitter/X, social platforms, crypto, web/news search, AI generation, SMS verification, and auditable ephemeral compute. Configure the xAPI AI or WebSocket Gateways, or use sandbox run for automatic quote/create/execute/cleanup. Use when the user mentions xapi, external services, or sandbox compute. +description: Access real-time external data and managed cloud sandboxes via the xapi CLI — Twitter/X, social platforms, domains and DNS, crypto and Web3, web/news search, AI generation, SMS verification, and auditable ephemeral compute. Configure the xAPI AI or WebSocket Gateways, or use sandbox run for automatic quote/create/execute/cleanup. Use when the user mentions xapi, external services, or sandbox compute. metadata: {"openclaw":{"emoji":"x","requires":{"anyBins":["npx"]},"primaryEnv":"XAPI_KEY"}} --- @@ -57,7 +57,7 @@ Use these flags where the command documents them: xapi offers two types of APIs under a unified interface: -1. **Capabilities** (`--source capability`) — Built-in APIs with known IDs (Twitter, crypto, AI, web search, news) +1. **Capabilities** (`--source capability`) — Built-in APIs with known IDs (Twitter, domains/DNS, crypto, AI, web search, news) 2. **Third-party APIs** (`--source api`) — Proxied services, discovered via `list`, `search`, or `services` Both types use the same discovery and call workflow. Use `--source capability` or `--source api` on commands that expose source filtering. @@ -452,6 +452,7 @@ Beyond built-in capabilities, xapi proxies **dozens** of third-party API service - **5SIM SMS** (`5sim-sms`) — SMS verification (virtual numbers, activation codes) - **Serper API** (`serper`) — 12 provider-native Google Search actions including web, images, news, maps, places, video, shopping, scholar, patents, autocomplete, Lens, and reviews. Eleven support mini-batch; Reviews does not. Read `guides/serper.md` before calling them - **OpenRouter API** (`openrouter`) — Multi-model AI gateway (chat, embeddings, audio transcription/speech, video) +- **Web3 infrastructure** — BlockPI RPC (`rpc`, 13 actions) and Binance Web3 API (`binance-web3-api`, 58 actions); read `guides/blockpi.md` or `guides/binance_web3.md` before calling them The full catalog also spans many other categories — crypto/on-chain data, CEX market data, stocks & macro, social platforms, news, weather, and more. Discover them with `search` / `services`. @@ -464,9 +465,7 @@ The full catalog also spans many other categories — crypto/on-chain data, CEX - **Insufficient balance** → Run `npx xapi-to topup --method stripe --amount 10` - **Unknown API ID** → Use `search` or `list` to find the correct ID, then `get` to check parameters -The CLI retries idempotent metadata reads and `task poll` for transient timeouts, network failures, `408`, `429`, and `502`–`504`. It does not automatically retry arbitrary `call` actions because the upstream may already have completed a write; confirm the result before manually retrying posts, payments, or other mutations. Ordinary JSON execution has a 60-second request ceiling. HTTP SSE streams and raw downloads instead use a 60-second no-data timeout, reset whenever a chunk arrives; override it with `XAPI_TRANSFER_IDLE_TIMEOUT_MS` when an upstream legitimately pauses longer. - -- Use `--page` and `--page-size` for pagination on `list`, `search`, and `services`. +The CLI retries idempotent metadata reads and `task poll` for transient timeouts, network failures, `408`, `429`, and `502`–`504`. It does not automatically retry arbitrary `call` actions because the upstream may already have completed a write; confirm the result before manually retrying posts, payments, or other mutations. Ordinary JSON execution has a 60-second request ceiling. HTTP SSE streams and raw downloads instead use a 60-second no-data timeout, reset whenever a chunk arrives; override it with `XAPI_TRANSFER_IDLE_TIMEOUT_MS` when an upstream legitimately pauses longer. Use `--page` and `--page-size` for pagination on `list`, `search`, and `services`. ## Specialized Guides @@ -482,6 +481,7 @@ When the user's task involves these workflows, read the corresponding guide file - **`guides/google_search.md`** — Google Search: web, realtime, news, image, video, scholar, maps, places, shopping - **`guides/serper.md`** — direct Serper v7 API: 12 provider-native actions, object-or-array mini-batches, Reviews pagination and batch exception, Lens, dynamic per-credit billing, and the current Webpage service boundary - **`guides/crypto.md`** — Crypto (加密货币): on-chain token price/overview/holders/security/OHLCV, wallet analytics, DEX pairs, CEX spot prices by symbol, news — covers contract-address vs symbol addressing and multi-chain +- **`guides/domains.md`**, **`guides/blockpi.md`**, **`guides/binance_web3.md`** — domain purchase and DNS writes, BlockPI EVM JSON-RPC, and the official Binance Web3 API catalog; read the matching guide before any purchase, mutation, transaction build, signing, or broadcast - **`guides/ai.md`** — AI (人工智能): synchronous or SSE-streamed text, embeddings, asynchronous image/video generation with `task wait`, text-to-speech, and speech-to-text - **`guides/ai_gateway.md`** — xAPI AI Gateway: Claude Code and Anthropic/OpenAI SDK setup, model discovery, routing strategies, streaming, fallback, routing/billing headers, direct media endpoints, and known limitations - **`guides/ws_gateway.md`** — xAPI WebSocket Gateway: OpenAI Realtime, streaming ASR/TTS, simultaneous interpretation, podcast generation, service/path routing, browser authentication, native binary protocols, limits, billing, close codes, and reconnects diff --git a/skills/xapi/guides/binance_web3.md b/skills/xapi/guides/binance_web3.md new file mode 100644 index 0000000..42e0e5e --- /dev/null +++ b/skills/xapi/guides/binance_web3.md @@ -0,0 +1,191 @@ +# Binance Web3 API Guide + +The official `Binance Web3 API` service uses the action prefix +`binance-web3-api.` and currently exposes 58 Crypto actions for market data, +address analytics, RWA data, DEX aggregation, wallet balances, transaction +data/building/broadcast, and DeFi data and transaction building. + +Do not confuse it with `binance-web3.` (Binance Web3 Intelligence) or +`binance-spot.` (Binance Spot). They are separate services with different +actions, schemas, and upstream behavior. + +## Discovery and shared limit + +The current service ID is `02a6d64c-dd64-4ff3-aa0b-d1d2eccdec67`: + +```bash +npx xapi-to list --source api \ + --service-id 02a6d64c-dd64-4ff3-aa0b-d1d2eccdec67 --page-size 100 +npx xapi-to get binance-web3-api.api_v1_dex_market_token_search +``` + +The live catalog currently declares a service-level limit of **10 requests per +second per user**, shared across every API key and all 58 endpoints. Pace the +combined workload, not each action independently. The current fixed listed +price is `$0/call`; re-check `get` because limits and pricing can change. + +GET actions take `{"method":"GET","params":{...}}` (plus `pathParams` when +the schema requires them). Chain identifiers are Binance IDs such as `"1"` +for Ethereum, `"56"` for BSC, and `"CT_501"` for Solana. They are not the +BlockPI network names or the built-in `crypto.*` chain enum. + +## Safe read examples + +Discover the current chain list before relying on remembered IDs: + +```bash +npx xapi-to call binance-web3-api.api_v1_dex_market_supported_chain \ + --input '{"method":"GET"}' +``` + +Search by symbol/address, then use the returned contract and chain ID: + +```bash +npx xapi-to call binance-web3-api.api_v1_dex_market_token_search --input '{ + "method":"GET","params":{"chains":"1,56,CT_501","search":"USDT"} +}' + +npx xapi-to call binance-web3-api.api_v1_dex_market_candles --input '{ + "method":"GET","params":{ + "binanceChainId":"1", + "tokenContractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7", + "bar":"1h","limit":100 + } +}' +``` + +`candles.limit` is currently 1–300. `before` and `after` are exclusive Unix +millisecond bounds. Read each endpoint's enum independently; for example, +portfolio `timeFrame` values differ from leaderboard values. + +RWA search supports ticker, company name, or contract address: + +```bash +npx xapi-to call binance-web3-api.api_v1_dex_market_rwa_search --input '{ + "method":"GET","params":{"keyword":"NVDA","platformId":"ondo"} +}' +``` + +`platformId` currently accepts `ondo` or `bstock` and is optional. + +## Quote and transaction boundary + +Aggregator quote amounts are positive integer strings in the sell token's +smallest unit. A quote is not a swap and does not authorize signing: + +```bash +npx xapi-to call binance-web3-api.api_v1_dex_aggregator_quote --input '{ + "method":"GET","params":{ + "amount":"1000000","binanceChainId":"56", + "fromTokenAddress":"0x55d398326f99059fF775485246999027B3197955", + "toTokenAddress":"0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + } +}' +``` + +The normal flow is `quote` → choose a returned route/`quoteId` (about 30-second +TTL) → `swap` to build transaction data. `quote-and-swap` combines the first +two steps when a vendor is selected. EVM approvals and swaps, Solana compiled +transactions/instructions, RFQ typed data, and DeFi transaction endpoints +return data for the caller to inspect, sign, and submit. They do not authorize +xAPI or an agent to sign on the user's behalf. + +Before any sign or broadcast step, obtain explicit approval for the selected +chain, wallet, token addresses, exact amounts, route/vendor, slippage, fees, +approval allowance, recipient, gas policy, and expected transaction effects. +Never pass a private key or seed phrase to xAPI. + +## Current POST schema gap + +At the time this guide was verified, none of the 19 current POST actions +exposed a `body` in its live `get` schema. Eighteen exposed only the fixed +`method: "POST"`; the one exception, `token_basic-info`, also exposes required +query `params` (`binanceChainId` and `tokenContractAddress`) and can be called +with those declared fields. The missing body affects balance-by-token, token +price/info, transaction simulation/broadcast/gas estimation, RFQ order +submission, and all 11 DeFi POST actions. + +Do not copy a Binance-native request body or invent a `body`. For an action that +needs request content, wait until `npx xapi-to get ` exposes it and +report the service-schema gap instead. The query-only token basic-info action +may be called exactly as its live `params` schema declares. Once fixed, the +live xAPI schema—not this snapshot—is authoritative. + +## Current action catalog (58) + +### DEX aggregation (9) + +- `binance-web3-api.api_v1_dex_aggregator_approve-transaction` +- `binance-web3-api.api_v1_dex_aggregator_history` +- `binance-web3-api.api_v1_dex_aggregator_order_{orderId}` +- `binance-web3-api.api_v1_dex_aggregator_quote` +- `binance-web3-api.api_v1_dex_aggregator_quote-and-swap` +- `binance-web3-api.api_v1_dex_aggregator_supported_chain` +- `binance-web3-api.api_v1_dex_aggregator_swap` +- `binance-web3-api.api_v1_dex_aggregator_swap-instruction` +- `binance-web3-api.api_v1_dex_aggregator_order_submit` + +### Wallet balances (3) + +- `binance-web3-api.api_v1_dex_balance_all-token-balances-by-address` +- `binance-web3-api.api_v1_dex_balance_supported_chain` +- `binance-web3-api.api_v1_dex_balance_token-balances-by-address` + +### Market, address profile, and RWA data (26) + +- `binance-web3-api.api_v1_dex_market_address-tracker_trades` +- `binance-web3-api.api_v1_dex_market_candles` +- `binance-web3-api.api_v1_dex_market_leaderboard_list` +- `binance-web3-api.api_v1_dex_market_memepump_tokenDevInfo` +- `binance-web3-api.api_v1_dex_market_portfolio_dex-history` +- `binance-web3-api.api_v1_dex_market_portfolio_overview` +- `binance-web3-api.api_v1_dex_market_portfolio_recent-pnl` +- `binance-web3-api.api_v1_dex_market_portfolio_supported_chain` +- `binance-web3-api.api_v1_dex_market_portfolio_token_latest-pnl` +- `binance-web3-api.api_v1_dex_market_rwa_platforms` +- `binance-web3-api.api_v1_dex_market_rwa_price` +- `binance-web3-api.api_v1_dex_market_rwa_search` +- `binance-web3-api.api_v1_dex_market_rwa_tokens` +- `binance-web3-api.api_v1_dex_market_rwa_underlying-market` +- `binance-web3-api.api_v1_dex_market_rwa_underlying-profile` +- `binance-web3-api.api_v1_dex_market_supported_chain` +- `binance-web3-api.api_v1_dex_market_token_advanced-info` +- `binance-web3-api.api_v1_dex_market_token_holder` +- `binance-web3-api.api_v1_dex_market_token_hot-token` +- `binance-web3-api.api_v1_dex_market_token_search` +- `binance-web3-api.api_v1_dex_market_token_top-liquidity` +- `binance-web3-api.api_v1_dex_market_token_top-trader` +- `binance-web3-api.api_v1_dex_market_trades` +- `binance-web3-api.api_v1_dex_market_price` +- `binance-web3-api.api_v1_dex_market_price-info` +- `binance-web3-api.api_v1_dex_market_token_basic-info` + +### Transaction data, building, and broadcast (9) + +- `binance-web3-api.api_v1_dex_post-transaction_orders` +- `binance-web3-api.api_v1_dex_post-transaction_transaction-detail-by-txhash` +- `binance-web3-api.api_v1_dex_post-transaction_transactions-by-address` +- `binance-web3-api.api_v1_dex_pre-transaction_block-height` +- `binance-web3-api.api_v1_dex_pre-transaction_gas-price` +- `binance-web3-api.api_v1_dex_pre-transaction_supported_chain` +- `binance-web3-api.api_v1_dex_pre-transaction_broadcast-transaction` +- `binance-web3-api.api_v1_dex_pre-transaction_gas-limit` +- `binance-web3-api.api_v1_dex_pre-transaction_simulate` + +### DeFi data and transaction building (11) + +- `binance-web3-api.api_v1_defi_data_investment_detail` +- `binance-web3-api.api_v1_defi_data_investment_list` +- `binance-web3-api.api_v1_defi_data_position_list` +- `binance-web3-api.api_v1_defi_data_protocol_detail` +- `binance-web3-api.api_v1_defi_data_protocol_list` +- `binance-web3-api.api_v1_defi_transaction_claim` +- `binance-web3-api.api_v1_defi_transaction_deposit` +- `binance-web3-api.api_v1_defi_transaction_lp-add` +- `binance-web3-api.api_v1_defi_transaction_lp-add_calculate` +- `binance-web3-api.api_v1_defi_transaction_lp-remove` +- `binance-web3-api.api_v1_defi_transaction_redeem` + +Use `list --service-id` to detect additions/removals and `get` immediately +before each call. Do not infer that similarly named endpoints share parameters, +enum values, pagination, or response shapes. diff --git a/skills/xapi/guides/blockpi.md b/skills/xapi/guides/blockpi.md new file mode 100644 index 0000000..72b97f8 --- /dev/null +++ b/skills/xapi/guides/blockpi.md @@ -0,0 +1,106 @@ +# BlockPI RPC Guide + +The `rpc` third-party service exposes EVM JSON-RPC through BlockPI. The current +catalog has one generic action and 12 legacy convenience actions over 60 +explicitly registered mainnets and testnets. The server supplies the BlockPI +partner credential; callers send only their xAPI key to xAPI and must never ask for, expose, or forward the upstream credential. + +Always inspect the live schema and price before calling: + +```bash +npx xapi-to get rpc.network +npx xapi-to list --source api --service-id 11a478df-6928-4bfb-8212-cf8eb2ae5249 +``` + +The catalog currently lists each action at `$0.000003/call`; treat `get` as the +authority because pricing and supported networks may change. + +## Generic JSON-RPC + +Prefer `rpc.network` for arbitrary EVM methods. It accepts exactly one JSON-RPC +request object, not a batch. Keep the HTTP method and JSON-RPC method separate: + +```bash +npx xapi-to call rpc.network --input '{ + "method":"POST", + "pathParams":{"network":"ethereum"}, + "body":{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]} +}' +``` + +The required outer fields are `method`, `pathParams`, and `body`. +`pathParams.network` selects the registered BlockPI network. The body requires +`jsonrpc: "2.0"` and the JSON-RPC `method`; `id` and `params` are optional. + +For example, read an address balance without exposing any provider key: + +```bash +npx xapi-to call rpc.network --input '{ + "method":"POST", + "pathParams":{"network":"base"}, + "body":{"jsonrpc":"2.0","id":"balance-1","method":"eth_getBalance", + "params":["0x0000000000000000000000000000000000000000","latest"]} +}' +``` + +## Registered networks + +The current `rpc.network` enum contains 60 values: + +```text +abstract, arbitrum, arbitrum-nova, arbitrum-sepolia, arc-testnet, +avalanche, avalanche-fuji, base, base-sepolia, berachain, blast, bsc, +bsc-testnet, celo, celo-sepolia, conflux-espace, cronos, ethereum, +ethereum-hoodi, ethereum-sepolia, etherlink, fantom, gnosis, hemi, +hyperliquid, ink, kaia, kaia-kairos, linea, linea-sepolia, mantle, merlin, +merlin-testnet, meter, metis, monad, monad-testnet, optimism, +optimism-sepolia, plasma, plume, polygon, polygon-amoy, robinhood, scroll, +scroll-sepolia, sei-evm, sei-testnet-evm, sonic, stable, story, taiko, +unichain, unichain-sepolia, viction, xlayer, zetachain-athens-evm, +zetachain-evm, zksync-era, zksync-era-sepolia +``` + +Do not normalize or invent aliases. Re-run `get rpc.network` and use its exact +enum when a network is absent or when the task depends on current support. + +## Legacy convenience actions + +These actions translate simple REST-shaped inputs to common EVM methods: + +- `rpc.network` — arbitrary single JSON-RPC request +- `rpc.chain_blockNumber` — latest block number +- `rpc.chain_call` — `eth_call` +- `rpc.chain_chainId` — chain ID +- `rpc.chain_gasPrice` — gas price +- `rpc.chain_getBalance` — native balance +- `rpc.chain_getBlockByHash` — block by hash +- `rpc.chain_getBlockByNumber` — block by number/tag +- `rpc.chain_getCode` — contract bytecode +- `rpc.chain_getLogs` — event logs +- `rpc.chain_getTransactionByHash` — transaction by hash +- `rpc.chain_getTransactionCount` — address nonce +- `rpc.chain_getTransactionReceipt` — transaction receipt + +Legacy actions use `pathParams.chain` plus action-specific query `params`. +Never infer those parameters from the raw JSON-RPC signature; inspect the exact +action first: + +```bash +npx xapi-to get rpc.chain_getBalance +npx xapi-to call rpc.chain_getBalance --input '{ + "method":"POST", + "pathParams":{"chain":"ethereum"}, + "params":{"address":"0x0000000000000000000000000000000000000000", + "block":"latest"} +}' +``` + +## Transaction safety + +Reads such as block, balance, code, logs, and receipt queries are non-mutating. +Methods such as `eth_sendRawTransaction` can create irreversible on-chain +effects even though they use the same generic action. Before submitting a +signed transaction, obtain explicit approval for the chain, sender, recipient, +value, calldata, gas policy, and transaction hash workflow. Do not send private +keys or seed phrases through xAPI, and do not automatically retry an ambiguous +broadcast. Check the transaction hash or sender nonce first. diff --git a/skills/xapi/guides/domains.md b/skills/xapi/guides/domains.md new file mode 100644 index 0000000..395c4d5 --- /dev/null +++ b/skills/xapi/guides/domains.md @@ -0,0 +1,131 @@ +# Domains and DNS Guide + +Use the built-in `domain.*` and `dns.*` capabilities to search, price, register, +list, and inspect domains, then manage their DNS records. These are capability +actions, not third-party API actions, so discover them with +`--source capability` and pass a flat JSON object to `--input`. + +Domain registration is a real, non-refundable purchase. DNS changes alter live +traffic. Read the current schema, show the user the exact target and price or +record change, and obtain explicit approval before either mutation. + +## Current actions + +| Action | Purpose | Mutation | +|---|---|---| +| `domain.search` | Registrar suggestions and one-year estimated prices | No | +| `domain.check` | Check exact-domain availability | No | +| `domain.price` | Read the current USD registration price | No | +| `domain.register` | Register a domain | **Purchase** | +| `domain.list` | List domains owned through xAPI | No | +| `domain.get` | Inspect one domain by `domain_id` | No | +| `dns.list` | List a domain's DNS records | No | +| `dns.upsert` | Create or update a DNS record | **Write** | +| `dns.delete` | Delete a DNS record | **Write** | + +Fetch the live schemas before use: + +```bash +npx xapi-to get-batch domain.search domain.check domain.price domain.register \ + domain.list domain.get dns.list dns.upsert dns.delete +``` + +## Search, check, and price + +`domain.search` accepts a keyword and up to 20 optional TLDs. Its availability +and one-year prices are suggestions, not a purchase quote. Search first, then +check and price the exact fully qualified domain: + +```bash +npx xapi-to call domain.search \ + --input '{"keyword":"example","tlds":["com","dev","ai"]}' + +npx xapi-to call domain.check --input '{"domain":"example.com"}' +npx xapi-to call domain.price --input '{"domain":"example.com","period":1}' +``` + +`domain.price` is the authoritative pre-registration price at call time. The +current USD billable price includes xAPI's fixed fee; non-USD registrar quotes +are unsupported. Re-price immediately before registration because availability +and upstream prices can change. + +## Register a domain + +Before calling `domain.register`: + +1. Re-run `domain.check` and `domain.price` for the exact domain and period. +2. Show the exact domain, period, current USD price, and `max_price_usd` ceiling. +3. Confirm the registrant contact details and obtain explicit purchase approval. +4. Create one idempotency key for that exact request and reuse it only for retries. + +The required contact fields are `first_name`, `last_name`, `address1`, `city`, +`state`, `postal_code`, `country`, `phone`, and `email`. `country` is a two-letter +ISO code. Phone numbers must use `+{country_code}.{number}`, for example +`+86.13800138000`. Do not log contact data or include it in task summaries. + +```bash +npx xapi-to call domain.register --input '{ + "domain":"example.com", + "period":1, + "max_price_usd":20, + "idempotency_key":"register-example-com-20260910", + "auto_renew":false, + "whois_privacy":true, + "contact":{ + "first_name":"Given","last_name":"Family", + "address1":"Street address","city":"City","state":"Region", + "postal_code":"000000","country":"CN", + "phone":"+86.13800138000","email":"owner@example.com" + } +}' +``` + +`max_price_usd` is a hard final-charge ceiling, not the expected price. +`auto_renew` must currently remain `false`; renewal billing is not available. +Registration is non-refundable. Do not retry with a new key after an ambiguous +failure: first inspect `domain.list` to determine whether the purchase completed. + +`domain.get` intentionally does not return the registrant contact. Treat that +privacy boundary as expected rather than assuming registration lost the data. + +## DNS workflow + +DNS actions use the xAPI `domain_id`, not the domain name. Resolve it with +`domain.list`, inspect the current records, and retain the stable `record_id`: + +```bash +npx xapi-to call domain.list --input '{"limit":50,"offset":0}' +npx xapi-to call dns.list --input '{"domain_id":""}' +``` + +Create a record by omitting both record identifiers: + +```bash +npx xapi-to call dns.upsert --input '{ + "domain_id":"", + "subdomain":"@","type":"A","value":"203.0.113.10", + "idempotency_key":"dns-create-root-a-20260910" +}' +``` + +Update an existing record with its stable `record_id`. `record_index` is a +legacy fallback whose meaning can change when the record set changes; use it +only when a live response lacks `record_id`. + +```bash +npx xapi-to call dns.upsert --input '{ + "domain_id":"","record_id":"", + "subdomain":"www","type":"CNAME","value":"example.com.", + "idempotency_key":"dns-update-www-20260910" +}' + +npx xapi-to call dns.delete --input '{ + "domain_id":"","record_id":"", + "idempotency_key":"dns-delete-record-20260910" +}' +``` + +For every write, confirm the domain, record type/name/value, and stable record +identifier. Reuse an idempotency key only for an identical retry; use a new key +when any requested value changes. After a successful write, call `dns.list` +again and verify the intended state instead of assuming propagation or success. diff --git a/src/tests/skill-live-services-guide.test.ts b/src/tests/skill-live-services-guide.test.ts new file mode 100644 index 0000000..d5721fe --- /dev/null +++ b/src/tests/skill-live-services-guide.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const skill = readFileSync(new URL('../../skills/xapi/SKILL.md', import.meta.url), 'utf8'); +const domains = readFileSync(new URL('../../skills/xapi/guides/domains.md', import.meta.url), 'utf8'); +const blockpi = readFileSync(new URL('../../skills/xapi/guides/blockpi.md', import.meta.url), 'utf8'); +const binance = readFileSync(new URL('../../skills/xapi/guides/binance_web3.md', import.meta.url), 'utf8'); + +describe('bundled xAPI live-service guides', () => { + it('routes the three service families without bloating the always-loaded skill', () => { + expect(skill.split('\n').length).toBeLessThan(500); + for (const guide of ['guides/domains.md', 'guides/blockpi.md', 'guides/binance_web3.md']) { + expect(skill).toContain(guide); + } + }); + + it('covers every domain and DNS capability with purchase/write safeguards', () => { + for (const action of [ + 'domain.search', 'domain.check', 'domain.price', 'domain.register', + 'domain.list', 'domain.get', 'dns.list', 'dns.upsert', 'dns.delete', + ]) expect(domains).toContain(`\`${action}\``); + + for (const safeguard of [ + 'non-refundable', 'explicit approval', 'max_price_usd', 'idempotency key', + 'record_id', 'record_index', 'domain.list', 'dns.list', + ]) expect(domains).toContain(safeguard); + }); + + it('covers the generic and 12 legacy BlockPI actions and credential boundary', () => { + const actionLines = blockpi.match(/^- `rpc\.[^`]+`/gm) ?? []; + expect(actionLines).toHaveLength(13); + for (const required of [ + '60 values', 'rpc.network', 'pathParams', 'jsonrpc', 'server supplies', + 'never ask for, expose, or forward', 'eth_sendRawTransaction', 'keys or seed phrases', + ]) expect(blockpi).toContain(required); + }); + + it('records all 58 Binance Web3 actions and the live POST-schema limitation', () => { + const actionLines = binance.match(/^- `binance-web3-api\.[^`]+`/gm) ?? []; + expect(actionLines).toHaveLength(58); + for (const required of [ + '10 requests per', 'shared across every API key and all 58 endpoints', + 'smallest unit', 'quoteId', 'explicit approval', 'private key', + 'none of the 19 current POST actions', 'exposed a `body`', + 'report the service-schema gap', 'token_basic-info', + 'binance-web3.', 'binance-spot.', + ]) expect(binance).toContain(required); + }); +}); From 137e8ab7b32febe176e2f619d6c472655bbcb244 Mon Sep 17 00:00:00 2001 From: GlacierLuo <1090490148@qq.com> Date: Thu, 10 Sep 2026 20:49:38 +0800 Subject: [PATCH 08/12] fix(skill): harden live service guidance --- skills/xapi/SKILL.md | 223 +++----------------- skills/xapi/guides/binance_web3.md | 107 ++++++---- skills/xapi/guides/blockpi.md | 8 +- src/tests/skill-live-services-guide.test.ts | 136 ++++++++++-- 4 files changed, 223 insertions(+), 251 deletions(-) diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index 8362ff4..c022120 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -1,6 +1,6 @@ --- name: xapi -description: Access real-time external data and managed cloud sandboxes via the xapi CLI — Twitter/X, social platforms, domains and DNS, crypto and Web3, web/news search, AI generation, SMS verification, and auditable ephemeral compute. Configure the xAPI AI or WebSocket Gateways, or use sandbox run for automatic quote/create/execute/cleanup. Use when the user mentions xapi, external services, or sandbox compute. +description: Access real-time external data and managed cloud sandboxes via the xapi CLI — Twitter/X, social platforms, domain purchase and DNS, normalized crypto, BlockPI RPC, Binance Web3 API, web/news search, AI generation, SMS verification, and auditable ephemeral compute. Configure the xAPI AI or WebSocket Gateways, or use sandbox run for automatic quote/create/execute/cleanup. Use when the user mentions xapi, external services, or sandbox compute. metadata: {"openclaw":{"emoji":"x","requires":{"anyBins":["npx"]},"primaryEnv":"XAPI_KEY"}} --- @@ -155,211 +155,58 @@ npx xapi-to call x-official.2_tweets --method POST --input '{"body":{"text":"Hel Always use `--input` with JSON for passing parameters. -### Twitter / X (9 APIs) +### Capability routing -```bash -# Get user profile -npx xapi-to call twitter.user_by_screen_name --input '{"screen_name":"elonmusk"}' - -# Get user's tweets -npx xapi-to call twitter.user_tweets --input '{"user_id":"44196397"}' - -# Get user's tweets and replies (timeline includes replies) -npx xapi-to call twitter.user_tweets_and_replies --input '{"user_id":"44196397"}' - -# Get tweet details and replies; video media include the highest-bitrate MP4 in video_url -npx xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' - -# Get user's media posts -npx xapi-to call twitter.user_media --input '{"user_id":"44196397"}' - -# Get followers / following -npx xapi-to call twitter.followers --input '{"user_id":"44196397"}' -npx xapi-to call twitter.following --input '{"user_id":"44196397"}' - -# Search tweets -npx xapi-to call twitter.search --input '{"raw_query":"bitcoin","count":20}' - -# Advanced search filters (provider x) -npx xapi-to call twitter.search --input '{"raw_query":"AI","from":"OpenAI","since":"2026-08-01","min_likes":100,"count":20}' - -# Get retweeters of a tweet -npx xapi-to call twitter.retweeters --input '{"tweet_id":"1234567890"}' -``` - -Note: Twitter user_id is a numeric ID. To get it, first call `twitter.user_by_screen_name` with the username, then extract `rest_id` from the response. - -Note: All `twitter.*` capabilities accept an optional `provider` — `"x"` (fapi.uk, default) or `"twitter"` (legacy upstream). Responses are normalized to an identical structure across providers, so you normally don't need to set it; pass `"provider":"twitter"` only to force the legacy upstream. - -Note: Timeline, reply, media, follower/following, retweeter, and search responses expose pagination cursors. Pass the previous response's bottom cursor back as `cursor`; see `guides/twitter.md` for the exact response field used by each endpoint. - -Note: For long-form **X Articles**, `twitter.tweet_detail` automatically returns the full article in `tweet.article`, including `text`, `markdown`, cover image, links, and timestamps. No raw GraphQL call is needed. - -Note: To download tweet videos, use the bundled `scripts/download_tweet_videos.sh` workflow documented in `guides/twitter.md`. It consumes `twitter.tweet_detail`'s normalized `media[].video_url`, handles multiple and nested quoted/retweeted videos, preserves automatic `x` → `twitter` failover, validates MP4 content, and publishes downloads atomically. Do not treat `media[].url` or `preview_url` as video files; they are preview images. - -### Crypto (17 registered APIs; 16 recommended) - -Two addressing models: - -- **On-chain by contract address** (`crypto.token.*`, `crypto.wallet.*`, `crypto.tx.*`, `crypto.dex.*`) — the `token`/`address`/`pair` field is a **contract/wallet address**, plus a `chain`. Supported chains: `eth`, `bsc` (default), `solana`, `base`, `arbitrum`, `polygon`, `optimism`, `avalanche`. -- **By symbol** (`crypto.cex.*`) — for coins without a contract address (e.g. "how much is BTC?"), use the CEX endpoints with a `symbol`. - -```bash -# --- Token by contract address --- - -# Price + 24h market data (aggregates multiple providers with fallback) -npx xapi-to call crypto.token.price --input '{"token":"0x55d398326f99059ff775485246999027b3197955","chain":"bsc"}' - -# Full overview: metadata + price + market in one call (preferred over metadata) -npx xapi-to call crypto.token.overview --input '{"token":"0x55d398326f99059ff775485246999027b3197955","chain":"bsc"}' - -# OHLCV candles (interval: 1m/5m/1h/1d…, default 1d) -npx xapi-to call crypto.token.ohlcv --input '{"token":"0x...","chain":"bsc","interval":"1h","limit":100}' - -# Top holders / top traders / security (honeypot, tax, etc.) -npx xapi-to call crypto.token.holders --input '{"token":"0x...","chain":"bsc"}' -npx xapi-to call crypto.token.holders --input '{"token":"0x...","chain":"bsc","cursor":""}' -npx xapi-to call crypto.token.top_traders --input '{"token":"0x...","chain":"bsc"}' -npx xapi-to call crypto.token.security --input '{"token":"0x...","chain":"bsc"}' - -# Trending tokens on a chain -npx xapi-to call crypto.token.trending --input '{"chain":"bsc","limit":20}' - -# Search tokens by name / symbol / address -npx xapi-to call crypto.token.search --input '{"query":"PEPE"}' - -# --- Wallet / transaction / DEX pair --- -npx xapi-to call crypto.wallet.balance --input '{"address":"0x...","chain":"bsc"}' -npx xapi-to call crypto.wallet.pnl --input '{"address":"0x...","chain":"bsc"}' -npx xapi-to call crypto.wallet.history --input '{"address":"0x...","chain":"bsc","limit":50}' -npx xapi-to call crypto.tx.detail --input '{"txHash":"0x...","chain":"bsc"}' -npx xapi-to call crypto.dex.pair --input '{"pair":"0x...","chain":"bsc"}' - -# --- CEX by symbol (no contract address needed) --- - -# Spot price of a coin by symbol -npx xapi-to call crypto.cex.price --input '{"symbol":"BTC"}' - -# CEX OHLCV candles -npx xapi-to call crypto.cex.ohlcv --input '{"symbol":"BTC","interval":"1d","limit":100}' - -# --- News --- -npx xapi-to call crypto.news --input '{"symbol":"BTC","limit":20}' -``` +- Twitter/X reads and writes → `guides/twitter.md`; use the specialized social guide when applicable. +- Domain search, purchase, and DNS management → `guides/domains.md` before any purchase or write. +- Normalized token, wallet, DEX, CEX, and crypto-news data → `guides/crypto.md`. +- General/news/image/video/scholar/maps/places/shopping search → `guides/google_search.md`. +- AI text, embeddings, image/video/audio generation, and transcription → `guides/ai.md`. -Note: `crypto.token.metadata` is **deprecated** — use `crypto.token.overview` instead (it returns metadata + price + market in one call). -Note: All `crypto.token.*`/`crypto.wallet.*`/etc. accept an optional `provider` to pin a specific upstream and disable automatic fallback. -Note: `crypto.token.holders`, `crypto.wallet.balance`, and `crypto.wallet.history` return an opaque `next_cursor` when another page is available. Pass it back unchanged as `cursor`; it pins pagination to the provider that issued it. - -### Web Search (9 APIs) +Common read-only examples: ```bash -# General web search +npx xapi-to call twitter.user_by_screen_name --input '{"screen_name":"OpenAI"}' npx xapi-to call web.search --input '{"q":"latest AI news"}' - -# Realtime web search with time filter -npx xapi-to call web.search.realtime --input '{"q":"breaking news","timeRange":"day"}' - -# News search -npx xapi-to call web.search.news --input '{"q":"crypto regulation"}' - -# Image search -npx xapi-to call web.search.image --input '{"q":"aurora borealis"}' - -# Video search -npx xapi-to call web.search.video --input '{"q":"machine learning tutorial"}' - -# Academic / scholar search -npx xapi-to call web.search.scholar --input '{"q":"transformer architecture"}' - -# Maps search -npx xapi-to call web.search.maps --input '{"q":"coffee shop near Times Square"}' - -# Places search (businesses with details) -npx xapi-to call web.search.places --input '{"q":"best ramen in Tokyo"}' - -# Shopping search -npx xapi-to call web.search.shopping --input '{"q":"mechanical keyboard"}' -``` - -### AI Text Processing (6 APIs) - -```bash -# Fast chat completion -npx xapi-to call ai.text.chat.fast --input '{"messages":[{"role":"user","content":"Explain quantum computing in one sentence"}]}' - -# Reasoning chat (more thorough) -npx xapi-to call ai.text.chat.reasoning --input '{"messages":[{"role":"user","content":"Analyze the pros and cons of microservices"}]}' - -# Auto chat — pass a model explicitly, gateway auto-routes to the best upstream with fallback -npx xapi-to call ai.text.chat.auto --input '{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"Hello"}]}' - -# Summarize text -npx xapi-to call ai.text.summarize --input '{"text":""}' - -# Rewrite text -npx xapi-to call ai.text.rewrite --input '{"text":"","mode":"formalize"}' - -# Generate embeddings -npx xapi-to call ai.embedding.generate --input '{"input":"hello world"}' -``` - -### AI Image & Video Generation (2 APIs — asynchronous) - -```bash -# Submit image generation (returns an async task) -npx xapi-to call ai.image.generate --input '{"prompt":"A serene mountain landscape at sunset, digital art","model":"gpt-image-2"}' - -# Submit video generation through OpenRouter (+ optional reference image) -npx xapi-to call ai.video.generate --input '{"prompt":"A cat playing piano in a jazz bar, cinematic"}' -``` - -Both capabilities return `{ "task_id": "...", "status": "pending", "poll_url": "..." }`. Wait for the result with `xapi-to task wait` (see below). Video generation uses provider `openrouter` and defaults to model `bytedance/seedance-2.0-fast`. - -### AI Speech Generation & Transcription (2 APIs) - -```bash -# Text to speech (synchronous; returns a base64-encoded binary envelope) -npx xapi-to call ai.audio.generate --input '{"text":"Hello world","model":"hexgrad/kokoro-82m","voice":"af_bella","format":"mp3"}' - -# Speech to text (audio.data is raw base64 without a data URI prefix) -npx xapi-to call ai.audio.transcribe --input '{"audio":{"data":"","format":"wav"},"model":"openai/whisper-large-v3"}' +npx xapi-to call crypto.cex.price --input '{"symbol":"BTC"}' ``` -### AI Gateway — Anthropic/OpenAI-compatible HTTP +### Crypto and Web3 selection -Use CLI capabilities for one-off agent calls. Use the public AI Gateway when configuring Claude Code, Anthropic/OpenAI SDKs, or applications that expect standard AI API protocols: +Use built-in `crypto.*` for normalized multi-provider market, token, wallet, +DEX, CEX, and news data. Use a specialized service instead when the user asks +for its provider-native behavior: -- Anthropic base URL: `https://ai.xapi.to/` -- OpenAI base URL: `https://ai.xapi.to//v1` -- Strategies: `default`, `cost`, `speed`, `quality` -- Authentication: use the xAPI key as `x-api-key`, `Authorization: Bearer`, or `XAPI-Key` +- BlockPI network RPC or an arbitrary EVM JSON-RPC method → `guides/blockpi.md`. +- Binance-native chain IDs, address analytics, RWA, aggregation, transaction, + wallet, or DeFi data → `guides/binance_web3.md`. -Read `guides/ai_gateway.md` before configuring a client. It covers supported endpoints, current strategy behavior, streaming, fallback, routing/billing headers, direct media endpoints, and compatibility limitations. +Do not redirect an explicit BlockPI or Binance Web3 request to `crypto.*` merely +because both are in the Crypto category. -### WebSocket Gateway — realtime and streaming audio +### Search selection -Use the WebSocket Gateway for persistent, full-duplex sessions such as OpenAI Realtime, streaming ASR, bidirectional TTS, simultaneous interpretation, and podcast generation: +Use normalized `web.search.*` capabilities for ordinary web, realtime, news, +image, video, scholar, maps, places, or shopping results; read +`guides/google_search.md`. Use direct `serper.*` actions only for provider-native +fields, mini-batches, Lens, Reviews, or other Serper-specific behavior; read +`guides/serper.md`. -- Unified base: `wss://ai.xapi.to/` -- Current paths include `/v1/realtime`, `/v1/asr`, `/v1/tts`, `/v1/ast`, and `/v1/podcast` -- Service-specific form: `wss://.p.xapi.to/` -- Server authentication: `XAPI-Key`, `Authorization: Bearer`, or `x-api-key` +### AI, async tasks, and gateways -Read `guides/ws_gateway.md` before opening a session. It explains path selection, browser-safe authentication, OpenAI Realtime usage, provider-native binary protocols, connection limits, billing, close codes, and reconnect behavior. - -### Async Tasks - -Some capabilities (currently `ai.image.generate` and `ai.video.generate`) run asynchronously and return a `task_id`. Prefer `task wait` to poll until a terminal status: +Read `guides/ai.md` for text, embeddings, synchronous/SSE calls, speech, +transcription, and asynchronous image/video generation. Use `task wait` for an +async capability's returned task ID: ```bash +npx xapi-to call ai.text.chat.fast \ + --input '{"messages":[{"role":"user","content":"Hello"}]}' npx xapi-to task wait --interval 2s --timeout 10m - -# Poll exactly once when the caller manages scheduling itself -npx xapi-to task poll ``` -`task wait` also accepts `--max-attempts`; duration flags support `ms`, `s`, `m`, and `h`. Status values are `pending` | `processing` | `succeeded` | `failed` | `expired`. It prints the terminal payload and exits nonzero for `failed` or `expired`. +For application integrations, read `guides/ai_gateway.md` before configuring an +Anthropic/OpenAI-compatible client. Read `guides/ws_gateway.md` before opening a +persistent Realtime, ASR, TTS, interpretation, or podcast WebSocket session. ## Input Format @@ -456,7 +303,7 @@ Beyond built-in capabilities, xapi proxies **dozens** of third-party API service The full catalog also spans many other categories — crypto/on-chain data, CEX market data, stocks & macro, social platforms, news, weather, and more. Discover them with `search` / `services`. -> For crypto data, prefer the built-in `crypto.*` capabilities above (they aggregate multiple upstreams with automatic fallback). +> For ordinary normalized crypto data, prefer built-in `crypto.*`. For an explicit BlockPI, Binance Web3, raw RPC, provider-native, transaction-building, or DeFi request, use the matching specialized guide and service. ## Error Handling diff --git a/skills/xapi/guides/binance_web3.md b/skills/xapi/guides/binance_web3.md index 42e0e5e..4e168d3 100644 --- a/skills/xapi/guides/binance_web3.md +++ b/skills/xapi/guides/binance_web3.md @@ -11,11 +11,12 @@ actions, schemas, and upstream behavior. ## Discovery and shared limit -The current service ID is `02a6d64c-dd64-4ff3-aa0b-d1d2eccdec67`: +Discover the service ID instead of persisting a database UUID in automation: ```bash +npx xapi-to services --category Crypto --page-size 100 npx xapi-to list --source api \ - --service-id 02a6d64c-dd64-4ff3-aa0b-d1d2eccdec67 --page-size 100 + --service-id --page-size 100 npx xapi-to get binance-web3-api.api_v1_dex_market_token_search ``` @@ -97,13 +98,12 @@ Never pass a private key or seed phrase to xAPI. ## Current POST schema gap -At the time this guide was verified, none of the 19 current POST actions -exposed a `body` in its live `get` schema. Eighteen exposed only the fixed -`method: "POST"`; the one exception, `token_basic-info`, also exposes required -query `params` (`binanceChainId` and `tokenContractAddress`) and can be called -with those declared fields. The missing body affects balance-by-token, token -price/info, transaction simulation/broadcast/gas estimation, RFQ order -submission, and all 11 DeFi POST actions. +At the time this guide was verified, the serving XAPI action catalog omitted +the `body` from all 18 body-bearing POST operations even though the provider's +current import payload contained those schemas. The nineteenth POST operation, +`token_basic-info`, is intentionally query-only and exposes required `params` +(`binanceChainId` and `tokenContractAddress`). This mismatch is serving-contract +drift, not evidence that the official operations take no input. Do not copy a Binance-native request body or invent a `body`. For an action that needs request content, wait until `npx xapi-to get ` exposes it and @@ -111,68 +111,87 @@ report the service-schema gap instead. The query-only token basic-info action may be called exactly as its live `params` schema declares. Once fixed, the live xAPI schema—not this snapshot—is authoritative. -## Current action catalog (58) +## Provider errors -### DEX aggregation (9) +Binance may return a successful HTTP response with a nonzero business code. +Treat `code: 0` as success and preserve the upstream code/message on failure. +In particular: -- `binance-web3-api.api_v1_dex_aggregator_approve-transaction` -- `binance-web3-api.api_v1_dex_aggregator_history` -- `binance-web3-api.api_v1_dex_aggregator_order_{orderId}` -- `binance-web3-api.api_v1_dex_aggregator_quote` -- `binance-web3-api.api_v1_dex_aggregator_quote-and-swap` -- `binance-web3-api.api_v1_dex_aggregator_supported_chain` -- `binance-web3-api.api_v1_dex_aggregator_swap` -- `binance-web3-api.api_v1_dex_aggregator_swap-instruction` -- `binance-web3-api.api_v1_dex_aggregator_order_submit` +- `40304` means a regional compliance restriction; changing parameters or + retrying cannot bypass it. +- `40104` means the configured upstream API key lacks permission for that + product; DeFi access requires explicit enablement. -### Wallet balances (3) +Do not automatically retry either error or misreport it as an xAPI-key failure. -- `binance-web3-api.api_v1_dex_balance_all-token-balances-by-address` -- `binance-web3-api.api_v1_dex_balance_supported_chain` -- `binance-web3-api.api_v1_dex_balance_token-balances-by-address` +## Current action catalog (58) -### Market, address profile, and RWA data (26) +### General Data (13) -- `binance-web3-api.api_v1_dex_market_address-tracker_trades` - `binance-web3-api.api_v1_dex_market_candles` -- `binance-web3-api.api_v1_dex_market_leaderboard_list` - `binance-web3-api.api_v1_dex_market_memepump_tokenDevInfo` +- `binance-web3-api.api_v1_dex_market_price` +- `binance-web3-api.api_v1_dex_market_price-info` +- `binance-web3-api.api_v1_dex_market_supported_chain` +- `binance-web3-api.api_v1_dex_market_token_advanced-info` +- `binance-web3-api.api_v1_dex_market_token_basic-info` +- `binance-web3-api.api_v1_dex_market_token_holder` +- `binance-web3-api.api_v1_dex_market_token_hot-token` +- `binance-web3-api.api_v1_dex_market_token_search` +- `binance-web3-api.api_v1_dex_market_token_top-liquidity` +- `binance-web3-api.api_v1_dex_market_token_top-trader` +- `binance-web3-api.api_v1_dex_market_trades` + +### Address Portfolio (7) + +- `binance-web3-api.api_v1_dex_market_address-tracker_trades` +- `binance-web3-api.api_v1_dex_market_leaderboard_list` - `binance-web3-api.api_v1_dex_market_portfolio_dex-history` - `binance-web3-api.api_v1_dex_market_portfolio_overview` - `binance-web3-api.api_v1_dex_market_portfolio_recent-pnl` - `binance-web3-api.api_v1_dex_market_portfolio_supported_chain` - `binance-web3-api.api_v1_dex_market_portfolio_token_latest-pnl` + +### RWA Data (6) + - `binance-web3-api.api_v1_dex_market_rwa_platforms` - `binance-web3-api.api_v1_dex_market_rwa_price` - `binance-web3-api.api_v1_dex_market_rwa_search` - `binance-web3-api.api_v1_dex_market_rwa_tokens` - `binance-web3-api.api_v1_dex_market_rwa_underlying-market` - `binance-web3-api.api_v1_dex_market_rwa_underlying-profile` -- `binance-web3-api.api_v1_dex_market_supported_chain` -- `binance-web3-api.api_v1_dex_market_token_advanced-info` -- `binance-web3-api.api_v1_dex_market_token_holder` -- `binance-web3-api.api_v1_dex_market_token_hot-token` -- `binance-web3-api.api_v1_dex_market_token_search` -- `binance-web3-api.api_v1_dex_market_token_top-liquidity` -- `binance-web3-api.api_v1_dex_market_token_top-trader` -- `binance-web3-api.api_v1_dex_market_trades` -- `binance-web3-api.api_v1_dex_market_price` -- `binance-web3-api.api_v1_dex_market_price-info` -- `binance-web3-api.api_v1_dex_market_token_basic-info` -### Transaction data, building, and broadcast (9) +### Trading API (9) + +- `binance-web3-api.api_v1_dex_aggregator_approve-transaction` +- `binance-web3-api.api_v1_dex_aggregator_history` +- `binance-web3-api.api_v1_dex_aggregator_order_{orderId}` +- `binance-web3-api.api_v1_dex_aggregator_quote` +- `binance-web3-api.api_v1_dex_aggregator_quote-and-swap` +- `binance-web3-api.api_v1_dex_aggregator_supported_chain` +- `binance-web3-api.api_v1_dex_aggregator_swap` +- `binance-web3-api.api_v1_dex_aggregator_swap-instruction` +- `binance-web3-api.api_v1_dex_aggregator_order_submit` + +### Transaction API (7) - `binance-web3-api.api_v1_dex_post-transaction_orders` -- `binance-web3-api.api_v1_dex_post-transaction_transaction-detail-by-txhash` -- `binance-web3-api.api_v1_dex_post-transaction_transactions-by-address` - `binance-web3-api.api_v1_dex_pre-transaction_block-height` -- `binance-web3-api.api_v1_dex_pre-transaction_gas-price` -- `binance-web3-api.api_v1_dex_pre-transaction_supported_chain` - `binance-web3-api.api_v1_dex_pre-transaction_broadcast-transaction` - `binance-web3-api.api_v1_dex_pre-transaction_gas-limit` +- `binance-web3-api.api_v1_dex_pre-transaction_gas-price` - `binance-web3-api.api_v1_dex_pre-transaction_simulate` +- `binance-web3-api.api_v1_dex_pre-transaction_supported_chain` + +### Wallet API (5) + +- `binance-web3-api.api_v1_dex_balance_all-token-balances-by-address` +- `binance-web3-api.api_v1_dex_balance_supported_chain` +- `binance-web3-api.api_v1_dex_balance_token-balances-by-address` +- `binance-web3-api.api_v1_dex_post-transaction_transaction-detail-by-txhash` +- `binance-web3-api.api_v1_dex_post-transaction_transactions-by-address` -### DeFi data and transaction building (11) +### DeFi Data and Transaction (11) - `binance-web3-api.api_v1_defi_data_investment_detail` - `binance-web3-api.api_v1_defi_data_investment_list` diff --git a/skills/xapi/guides/blockpi.md b/skills/xapi/guides/blockpi.md index 72b97f8..fb57e61 100644 --- a/skills/xapi/guides/blockpi.md +++ b/skills/xapi/guides/blockpi.md @@ -9,7 +9,7 @@ Always inspect the live schema and price before calling: ```bash npx xapi-to get rpc.network -npx xapi-to list --source api --service-id 11a478df-6928-4bfb-8212-cf8eb2ae5249 +npx xapi-to search "BlockPI RPC" --source api --page-size 100 ``` The catalog currently lists each action at `$0.000003/call`; treat `get` as the @@ -32,6 +32,12 @@ The required outer fields are `method`, `pathParams`, and `body`. `pathParams.network` selects the registered BlockPI network. The body requires `jsonrpc: "2.0"` and the JSON-RPC `method`; `id` and `params` are optional. +The generic route fails closed with HTTP 503 when the server-side partner key +is unavailable; it never silently sends a raw RPC call without that credential. +The legacy convenience routes have a different availability contract and may +fall back to their configured public RPC endpoints. Diagnose the two route +families separately instead of treating every BlockPI 503 as a bad request. + For example, read an address balance without exposing any provider key: ```bash diff --git a/src/tests/skill-live-services-guide.test.ts b/src/tests/skill-live-services-guide.test.ts index d5721fe..a890f85 100644 --- a/src/tests/skill-live-services-guide.test.ts +++ b/src/tests/skill-live-services-guide.test.ts @@ -6,44 +6,144 @@ const domains = readFileSync(new URL('../../skills/xapi/guides/domains.md', impo const blockpi = readFileSync(new URL('../../skills/xapi/guides/blockpi.md', import.meta.url), 'utf8'); const binance = readFileSync(new URL('../../skills/xapi/guides/binance_web3.md', import.meta.url), 'utf8'); +const DOMAIN_ACTIONS = [ + 'domain.search', 'domain.check', 'domain.price', 'domain.register', + 'domain.list', 'domain.get', 'dns.list', 'dns.upsert', 'dns.delete', +]; + +const BLOCKPI_ACTIONS = [ + 'rpc.network', 'rpc.chain_blockNumber', 'rpc.chain_call', 'rpc.chain_chainId', + 'rpc.chain_gasPrice', 'rpc.chain_getBalance', 'rpc.chain_getBlockByHash', + 'rpc.chain_getBlockByNumber', 'rpc.chain_getCode', 'rpc.chain_getLogs', + 'rpc.chain_getTransactionByHash', 'rpc.chain_getTransactionCount', + 'rpc.chain_getTransactionReceipt', +]; + +const BINANCE_ACTIONS = [ + 'binance-web3-api.api_v1_dex_market_candles', + 'binance-web3-api.api_v1_dex_market_memepump_tokenDevInfo', + 'binance-web3-api.api_v1_dex_market_price', + 'binance-web3-api.api_v1_dex_market_price-info', + 'binance-web3-api.api_v1_dex_market_supported_chain', + 'binance-web3-api.api_v1_dex_market_token_advanced-info', + 'binance-web3-api.api_v1_dex_market_token_basic-info', + 'binance-web3-api.api_v1_dex_market_token_holder', + 'binance-web3-api.api_v1_dex_market_token_hot-token', + 'binance-web3-api.api_v1_dex_market_token_search', + 'binance-web3-api.api_v1_dex_market_token_top-liquidity', + 'binance-web3-api.api_v1_dex_market_token_top-trader', + 'binance-web3-api.api_v1_dex_market_trades', + 'binance-web3-api.api_v1_dex_market_address-tracker_trades', + 'binance-web3-api.api_v1_dex_market_leaderboard_list', + 'binance-web3-api.api_v1_dex_market_portfolio_dex-history', + 'binance-web3-api.api_v1_dex_market_portfolio_overview', + 'binance-web3-api.api_v1_dex_market_portfolio_recent-pnl', + 'binance-web3-api.api_v1_dex_market_portfolio_supported_chain', + 'binance-web3-api.api_v1_dex_market_portfolio_token_latest-pnl', + 'binance-web3-api.api_v1_dex_market_rwa_platforms', + 'binance-web3-api.api_v1_dex_market_rwa_price', + 'binance-web3-api.api_v1_dex_market_rwa_search', + 'binance-web3-api.api_v1_dex_market_rwa_tokens', + 'binance-web3-api.api_v1_dex_market_rwa_underlying-market', + 'binance-web3-api.api_v1_dex_market_rwa_underlying-profile', + 'binance-web3-api.api_v1_dex_aggregator_approve-transaction', + 'binance-web3-api.api_v1_dex_aggregator_history', + 'binance-web3-api.api_v1_dex_aggregator_order_{orderId}', + 'binance-web3-api.api_v1_dex_aggregator_quote', + 'binance-web3-api.api_v1_dex_aggregator_quote-and-swap', + 'binance-web3-api.api_v1_dex_aggregator_supported_chain', + 'binance-web3-api.api_v1_dex_aggregator_swap', + 'binance-web3-api.api_v1_dex_aggregator_swap-instruction', + 'binance-web3-api.api_v1_dex_aggregator_order_submit', + 'binance-web3-api.api_v1_dex_post-transaction_orders', + 'binance-web3-api.api_v1_dex_pre-transaction_block-height', + 'binance-web3-api.api_v1_dex_pre-transaction_broadcast-transaction', + 'binance-web3-api.api_v1_dex_pre-transaction_gas-limit', + 'binance-web3-api.api_v1_dex_pre-transaction_gas-price', + 'binance-web3-api.api_v1_dex_pre-transaction_simulate', + 'binance-web3-api.api_v1_dex_pre-transaction_supported_chain', + 'binance-web3-api.api_v1_dex_balance_all-token-balances-by-address', + 'binance-web3-api.api_v1_dex_balance_supported_chain', + 'binance-web3-api.api_v1_dex_balance_token-balances-by-address', + 'binance-web3-api.api_v1_dex_post-transaction_transaction-detail-by-txhash', + 'binance-web3-api.api_v1_dex_post-transaction_transactions-by-address', + 'binance-web3-api.api_v1_defi_data_investment_detail', + 'binance-web3-api.api_v1_defi_data_investment_list', + 'binance-web3-api.api_v1_defi_data_position_list', + 'binance-web3-api.api_v1_defi_data_protocol_detail', + 'binance-web3-api.api_v1_defi_data_protocol_list', + 'binance-web3-api.api_v1_defi_transaction_claim', + 'binance-web3-api.api_v1_defi_transaction_deposit', + 'binance-web3-api.api_v1_defi_transaction_lp-add', + 'binance-web3-api.api_v1_defi_transaction_lp-add_calculate', + 'binance-web3-api.api_v1_defi_transaction_lp-remove', + 'binance-web3-api.api_v1_defi_transaction_redeem', +]; + +function listedActions(markdown: string, prefix: string): string[] { + return [...markdown.matchAll(/^- `([^`]+)`/gm)] + .map((match) => match[1]) + .filter((action) => action.startsWith(prefix)); +} + +function expectValidJsonExamples(markdown: string, expectedCount: number): void { + const examples = [...markdown.matchAll(/--input '([\s\S]*?)'/g)].map((match) => match[1]); + expect(examples).toHaveLength(expectedCount); + for (const example of examples) expect(() => JSON.parse(example)).not.toThrow(); +} + +function expectBalancedFences(markdown: string): void { + expect((markdown.match(/^```/gm) ?? []).length % 2).toBe(0); +} + describe('bundled xAPI live-service guides', () => { - it('routes the three service families without bloating the always-loaded skill', () => { - expect(skill.split('\n').length).toBeLessThan(500); + it('keeps the root skill as a concise router to all three guides', () => { + expect(skill.split('\n').length).toBeLessThan(400); for (const guide of ['guides/domains.md', 'guides/blockpi.md', 'guides/binance_web3.md']) { expect(skill).toContain(guide); } + expect(skill).toContain('Do not redirect an explicit BlockPI or Binance Web3 request'); }); - it('covers every domain and DNS capability with purchase/write safeguards', () => { - for (const action of [ - 'domain.search', 'domain.check', 'domain.price', 'domain.register', - 'domain.list', 'domain.get', 'dns.list', 'dns.upsert', 'dns.delete', - ]) expect(domains).toContain(`\`${action}\``); - + it('covers the exact domain/DNS action set and purchase/write safeguards', () => { + for (const action of DOMAIN_ACTIONS) expect(domains).toContain(`\`${action}\``); for (const safeguard of [ 'non-refundable', 'explicit approval', 'max_price_usd', 'idempotency key', 'record_id', 'record_index', 'domain.list', 'dns.list', ]) expect(domains).toContain(safeguard); + expectValidJsonExamples(domains, 9); + expectBalancedFences(domains); }); - it('covers the generic and 12 legacy BlockPI actions and credential boundary', () => { - const actionLines = blockpi.match(/^- `rpc\.[^`]+`/gm) ?? []; - expect(actionLines).toHaveLength(13); + it('covers the exact BlockPI action set, credential boundary, and failure modes', () => { + expect(listedActions(blockpi, 'rpc.')).toEqual(BLOCKPI_ACTIONS); for (const required of [ - '60 values', 'rpc.network', 'pathParams', 'jsonrpc', 'server supplies', - 'never ask for, expose, or forward', 'eth_sendRawTransaction', 'keys or seed phrases', + '60 values', 'pathParams', 'jsonrpc', 'server supplies', + 'never ask for, expose, or forward', 'eth_sendRawTransaction', + 'keys or seed phrases', 'fails closed', 'public RPC endpoints', ]) expect(blockpi).toContain(required); + expectValidJsonExamples(blockpi, 3); + expectBalancedFences(blockpi); + }); + + it('records the exact 58 Binance actions under the official product taxonomy', () => { + expect(listedActions(binance, 'binance-web3-api.')).toEqual(BINANCE_ACTIONS); + for (const heading of [ + 'General Data (13)', 'Address Portfolio (7)', 'RWA Data (6)', + 'Trading API (9)', 'Transaction API (7)', 'Wallet API (5)', + 'DeFi Data and Transaction (11)', + ]) expect(binance).toContain(heading); }); - it('records all 58 Binance Web3 actions and the live POST-schema limitation', () => { - const actionLines = binance.match(/^- `binance-web3-api\.[^`]+`/gm) ?? []; - expect(actionLines).toHaveLength(58); + it('documents Binance safety, limits, serving-contract drift, and provider errors', () => { for (const required of [ '10 requests per', 'shared across every API key and all 58 endpoints', 'smallest unit', 'quoteId', 'explicit approval', 'private key', - 'none of the 19 current POST actions', 'exposed a `body`', - 'report the service-schema gap', 'token_basic-info', + 'all 18 body-bearing POST operations', 'serving-contract', + 'report the service-schema gap', '40304', '40104', 'binance-web3.', 'binance-spot.', ]) expect(binance).toContain(required); + expectValidJsonExamples(binance, 5); + expectBalancedFences(binance); }); }); From 7845ebfe079c9b5f4168d72762546e4cf89a1487 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Tue, 8 Sep 2026 21:23:47 +0800 Subject: [PATCH 09/12] feat(workers): add project deployment commands and agent templates --- README.md | 235 ++- bun.lock | 11 +- package.json | 7 +- schemas/worker-project.v1.schema.json | 118 ++ skills/xapi/SKILL.md | 40 +- skills/xapi/guides/workers.md | 396 +++++ src/commands/workers.ts | 1296 +++++++++++++++++ src/index.ts | 10 + src/tests/skill-workers-guide.test.ts | 61 + src/tests/workers-billing-output.test.ts | 251 ++++ src/tests/workers-client.test.ts | 323 ++++ src/tests/workers-deployment-state.test.ts | 79 + src/tests/workers-init.test.ts | 295 ++++ src/tests/workers-logs.test.ts | 190 +++ src/tests/workers-metering-output.test.ts | 26 + src/tests/workers-plan-output.test.ts | 135 ++ src/tests/workers-plan.test.ts | 355 +++++ src/tests/workers-project.test.ts | 163 +++ src/tests/workers-promote.test.ts | 318 ++++ src/tests/workers-push-output.test.ts | 82 ++ src/tests/workers-push.test.ts | 544 +++++++ src/tests/workers-rollback.test.ts | 254 ++++ src/tests/workers-wrangler-import.test.ts | 183 +++ src/workers-billing-output.ts | 309 ++++ src/workers-client.ts | 562 +++++++ src/workers-deployment-state.ts | 48 + src/workers-init.ts | 318 ++++ src/workers-logs.ts | 293 ++++ src/workers-metering-output.ts | 42 + src/workers-plan-output.ts | 239 +++ src/workers-plan.ts | 716 +++++++++ src/workers-project.ts | 293 ++++ src/workers-promote.ts | 500 +++++++ src/workers-push-output.ts | 63 + src/workers-push.ts | 1038 +++++++++++++ src/workers-rollback.ts | 412 ++++++ src/workers-templates.ts | 332 +++++ src/workers-wrangler-import.ts | 777 ++++++++++ templates/agent/files/src/index.ts | 39 + templates/agent/template.json | 14 + templates/chat/files/src/index.ts | 40 + templates/chat/template.json | 14 + templates/persistent-agent/files/TEMPLATE.md | 41 + .../files/migrations/0001_init.sql | 9 + .../persistent-agent/files/scripts/smoke.mjs | 35 + templates/persistent-agent/files/src/index.ts | 610 ++++++++ templates/persistent-agent/template.json | 26 + templates/webhook/files/src/index.ts | 16 + templates/webhook/template.json | 14 + templates/worker/files/src/index.ts | 16 + templates/worker/template.json | 14 + 51 files changed, 12147 insertions(+), 55 deletions(-) create mode 100644 schemas/worker-project.v1.schema.json create mode 100644 skills/xapi/guides/workers.md create mode 100644 src/commands/workers.ts create mode 100644 src/tests/skill-workers-guide.test.ts create mode 100644 src/tests/workers-billing-output.test.ts create mode 100644 src/tests/workers-client.test.ts create mode 100644 src/tests/workers-deployment-state.test.ts create mode 100644 src/tests/workers-init.test.ts create mode 100644 src/tests/workers-logs.test.ts create mode 100644 src/tests/workers-metering-output.test.ts create mode 100644 src/tests/workers-plan-output.test.ts create mode 100644 src/tests/workers-plan.test.ts create mode 100644 src/tests/workers-project.test.ts create mode 100644 src/tests/workers-promote.test.ts create mode 100644 src/tests/workers-push-output.test.ts create mode 100644 src/tests/workers-push.test.ts create mode 100644 src/tests/workers-rollback.test.ts create mode 100644 src/tests/workers-wrangler-import.test.ts create mode 100644 src/workers-billing-output.ts create mode 100644 src/workers-client.ts create mode 100644 src/workers-deployment-state.ts create mode 100644 src/workers-init.ts create mode 100644 src/workers-logs.ts create mode 100644 src/workers-metering-output.ts create mode 100644 src/workers-plan-output.ts create mode 100644 src/workers-plan.ts create mode 100644 src/workers-project.ts create mode 100644 src/workers-promote.ts create mode 100644 src/workers-push-output.ts create mode 100644 src/workers-push.ts create mode 100644 src/workers-rollback.ts create mode 100644 src/workers-templates.ts create mode 100644 src/workers-wrangler-import.ts create mode 100644 templates/agent/files/src/index.ts create mode 100644 templates/agent/template.json create mode 100644 templates/chat/files/src/index.ts create mode 100644 templates/chat/template.json create mode 100644 templates/persistent-agent/files/TEMPLATE.md create mode 100644 templates/persistent-agent/files/migrations/0001_init.sql create mode 100644 templates/persistent-agent/files/scripts/smoke.mjs create mode 100644 templates/persistent-agent/files/src/index.ts create mode 100644 templates/persistent-agent/template.json create mode 100644 templates/webhook/files/src/index.ts create mode 100644 templates/webhook/template.json create mode 100644 templates/worker/files/src/index.ts create mode 100644 templates/worker/template.json diff --git a/README.md b/README.md index 1ee80fc..f88002c 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,175 @@ XAPI_MODEL=deepseek-v4-pro \ npm run example:sandbox:openai ``` +### Hosted Workers Commands + +Inspect delayed storage collection separately from the financial ledger: + +```bash +xapi-to workers metering --env preview +xapi-to workers metering --env preview --json +``` + +Human output lists each resource's UTC collection window, source status, sample +count, last sample and retry times. Missing samples are not zero usage; observed +samples are not final settlement. An empty or truncated list does not prove +complete history. This source read is independent of the billing snapshot. + +`workers` manages continuously addressable JavaScript applications on +xAPI-hosted Cloudflare Workers for Platforms. +It is separate from `sandbox`: use Sandbox for arbitrary shell/build/GPU work, +and Workers for HTTP, WebSocket, Webhook, Cron, and persistent Agent entrypoints. + +```bash +# New project: build, create/update resources, deploy preview, then promote the +# exact tested Artifact to production. +xapi workers templates +xapi workers init my-agent --template persistent-agent +cd my-agent +# Review the plan before resources are created. +xapi workers plan --env preview +xapi workers push --env preview +xapi workers promote --to production + +# Existing Cloudflare Worker: Wrangler remains the source of runtime config. +cd existing-worker +xapi workers init --from-wrangler ./wrangler.jsonc +xapi workers plan --env preview +xapi workers push --env preview + +# Code rollback never rolls back KV/D1/R2/DO/Queue/Workflow data or Secrets. +xapi workers rollback --env production --to previous + +# Follow Tail Worker logs and correlate one request or deployment. +xapi workers logs --env production --tail --since 10m +xapi workers logs --env production --request-id +``` + +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 +environment-specific resources and returns their binding state; secret values +remain a separate operation: + +```bash +export APP_TOKEN='replace-with-an-incoming-request-token' +export MODEL_KEY='replace-with-an-ai.xapi.to-key' +xapi workers secrets set APP_TOKEN --env preview --from-env APP_TOKEN +xapi workers secrets set MODEL_KEY --env preview --from-env MODEL_KEY +``` + +The project workflow works without Git. `xapi.worker.json` may be committed, +but Secret values must stay in environment variables or the encrypted Secret +store. `push` never silently deletes extra stateful resources or Secrets. + +Deployment identity includes the code Artifact, remote resource identities, +Secret versions, environment bindings and compatibility settings. Changing only +resources or compatibility settings therefore deploys again; repeating an +unchanged push reuses the current activation. Older deployments without this +configuration fingerprint require one deployment to establish the baseline. + +Removing a resource from `xapi.worker.json` does **not** unbind or destroy it: +`plan` reports `MANUAL`, and the resource remains billable and its reserve stays +frozen. To destroy it, first stop application access to that resource and remove +its declaration, run `workers resources delete --env +preview --yes`, then push again to publish the reduced binding set. Do not keep +the declaration, or a subsequent push will create a replacement resource. +If deletion fails, the cloud resource and reserve remain; inspect the reported +error rather than assuming the data has been removed. Preserve a backup before +deleting data you need. A successful deployment alone is not proof of deletion +or final billing settlement. + +In CI, +set `XAPI_KEY` and `XAPI_API_HOST` explicitly, use a Key restricted to the target +Worker, and pass `--non-interactive`; safety preflights are still enforced: + +```bash +export XAPI_API_HOST=test.xapi.to +export XAPI_KEY="$CI_XAPI_KEY" +xapi workers plan --env preview --format json +xapi workers push --env preview --non-interactive +xapi workers promote --to production --non-interactive +``` + +The lower-level commands remain available for diagnosis and custom automation: + +```bash +# API keys need workers:read / workers:write scopes. +xapi-to workers provider-status +xapi-to workers capabilities --format table +xapi-to workers bindings --format table + +# Both environment budgets are explicit ($0.10-$100/day). +xapi-to workers create \ + --name "Daily research agent" \ + --slug daily-research-agent \ + --template agent \ + --preview-budget 0.25 \ + --production-budget 2 + +# Build locally or in CI, then upload the single bundled ES module. +xapi-to workers upload \ + --file dist/worker.mjs \ + --idempotency-key artifact-v1 + +xapi-to workers deploy \ + --artifact \ + --env preview \ + --idempotency-key preview-v1 + +# Add per-environment state and object storage, then deploy again so the +# bindings are attached to that User Worker. +xapi-to workers resources create \ + --env preview --type kv --binding STATE +xapi-to workers resources create \ + --env preview --type r2 --binding FILES + +# Secrets are encrypted at rest; prefer reading them from a local env variable. +MODEL_KEY='...' xapi-to workers secrets set MODEL_KEY \ + --env preview --from-env MODEL_KEY +xapi-to workers resources list --env preview --format table +xapi-to workers secrets list --env preview --format table + +# Durable Agent state, asynchronous tasks, workflows, and persistent schedules. +xapi-to workers resources create \ + --env preview --type do --binding AGENT_STATE --class-name AgentState +xapi-to workers resources create \ + --env preview --type queue --binding TASK_QUEUE +xapi-to workers resources create \ + --env preview --type workflow --binding AGENT_WORKFLOW +# Queue includes an xAPI-managed consumer. Send a local route envelope from +# Worker code; delivery is at least once, so make /tasks/run idempotent: +# await env.TASK_QUEUE.send({ +# path: '/tasks/run', method: 'POST', body: { taskId: 'task_123' } +# }); +xapi-to workers schedules create \ + --name heartbeat --cron "*/15 * * * *" --timezone UTC \ + --env preview --path /cron --method POST + +# Optional: build source in an ephemeral xAPI Sandbox. A successful result +# contains artifactId, which is deployed exactly like an upload. +xapi-to workers build \ + --project . \ + --entrypoint src/index.ts \ + --command "npm install --ignore-scripts && npm run build" \ + --output dist/worker.mjs \ + --idempotency-key build-v1 + +xapi-to workers get --format pretty +xapi-to workers audit --format table +xapi-to workers invocations --env production --format table +xapi-to workers logs --env production --format table +xapi-to workers usage --env production --format pretty +xapi-to workers billing-status --format pretty +xapi-to workers domains list --format table +xapi-to workers budget production --daily-usd 3 +xapi-to workers delete --yes +``` + +Set `XAPI_API_HOST=test.xapi.to` for the test control plane. Mutating requests +are not retried automatically; when a deployment result is uncertain, inspect +the Worker and retry with the same idempotency key. + ### OAuth Bind third-party OAuth accounts (e.g. Twitter) to your API key. @@ -429,17 +598,17 @@ xapi-to list --format table # human-readable table ## Environment Variables -| Variable | Description | -|---|---| -| `XAPI_KEY` | API key (overrides config file) | -| `XAPI_API_KEY` | Compatible API key alias (overrides config file; lower priority than `XAPI_KEY`) | -| `XAPI_SANDBOX_KEY` | Sandbox-only credential for OpenAI SandboxAgent examples/tests | -| `XAPI_AI_KEY` | AI Gateway credential for OpenAI-compatible model calls | -| `XAPI_ACTION_HOST` | Action service host (default: `action.xapi.to`) | -| `XAPI_API_HOST` | Auth/account service host (default: `api.xapi.to`) | -| `XAPI_SANDBOX_HOST` | Sandbox gateway host (default: `sandbox.xapi.to`) | -| `XAPI_OUTPUT` | Default output format (`json`\|`pretty`\|`table`) | -| `XAPI_TRANSFER_IDLE_TIMEOUT_MS` | SSE/download idle timeout in milliseconds (default: `60000`) | +| Variable | Description | +| ------------------------------- | -------------------------------------------------------------------------------- | +| `XAPI_KEY` | API key (overrides config file) | +| `XAPI_API_KEY` | Compatible API key alias (overrides config file; lower priority than `XAPI_KEY`) | +| `XAPI_SANDBOX_KEY` | Sandbox-only credential for OpenAI SandboxAgent examples/tests | +| `XAPI_AI_KEY` | AI Gateway credential for OpenAI-compatible model calls | +| `XAPI_ACTION_HOST` | Action service host (default: `action.xapi.to`) | +| `XAPI_API_HOST` | Auth/account service host (default: `api.xapi.to`) | +| `XAPI_SANDBOX_HOST` | Sandbox gateway host (default: `sandbox.xapi.to`) | +| `XAPI_OUTPUT` | Default output format (`json`\|`pretty`\|`table`) | +| `XAPI_TRANSFER_IDLE_TIMEOUT_MS` | SSE/download idle timeout in milliseconds (default: `60000`) | Config is stored at `~/.xapi/config.json`. @@ -449,28 +618,28 @@ This is a small quick-reference subset, not the complete or permanently fixed catalog. Use `xapi-to list --source capability`, `search`, and `get` for the current IDs and schemas. -| ID | Description | -|---|---| -| `twitter.tweet_detail` | Get tweet details and replies | -| `twitter.user_by_screen_name` | Get user profile by username | -| `twitter.user_tweets` | Get tweets from a user | -| `twitter.user_tweets_and_replies` | Get tweets and replies from a user | -| `twitter.user_media` | Get media posts from a user | -| `twitter.following` | Get user following list | -| `twitter.followers` | Get user followers | -| `twitter.retweeters` | Get tweet retweeters | -| `twitter.search` | Search tweets | -| `ai.text.chat.fast` | Fast AI chat completion | -| `ai.text.chat.reasoning` | Advanced reasoning chat | -| `ai.text.chat.auto` | Model-selected chat with provider fallback | -| `ai.text.summarize` | Summarize long text | -| `ai.text.rewrite` | Rewrite text with different styles | -| `ai.embedding.generate` | Generate vector embeddings | -| `web.search` | Web search | -| `web.search.realtime` | Realtime web search with time filters | -| `web.search.news` | News search | -| `crypto.token.price` | Crypto token price and changes | -| `crypto.token.metadata` | Crypto token metadata | +| ID | Description | +| --------------------------------- | ------------------------------------------ | +| `twitter.tweet_detail` | Get tweet details and replies | +| `twitter.user_by_screen_name` | Get user profile by username | +| `twitter.user_tweets` | Get tweets from a user | +| `twitter.user_tweets_and_replies` | Get tweets and replies from a user | +| `twitter.user_media` | Get media posts from a user | +| `twitter.following` | Get user following list | +| `twitter.followers` | Get user followers | +| `twitter.retweeters` | Get tweet retweeters | +| `twitter.search` | Search tweets | +| `ai.text.chat.fast` | Fast AI chat completion | +| `ai.text.chat.reasoning` | Advanced reasoning chat | +| `ai.text.chat.auto` | Model-selected chat with provider fallback | +| `ai.text.summarize` | Summarize long text | +| `ai.text.rewrite` | Rewrite text with different styles | +| `ai.embedding.generate` | Generate vector embeddings | +| `web.search` | Web search | +| `web.search.realtime` | Realtime web search with time filters | +| `web.search.news` | News search | +| `crypto.token.price` | Crypto token price and changes | +| `crypto.token.metadata` | Crypto token metadata | ## Security diff --git a/bun.lock b/bun.lock index 019a36f..dea379e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,9 @@ "name": "xapi-to", "dependencies": { "@openai/agents": "0.15.0", + "acorn": "^8.18.0", + "jsonc-parser": "^3.3.1", + "smol-toml": "^1.8.0", "zod": "^4.0.0", }, "devDependencies": { @@ -147,7 +150,7 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], @@ -187,6 +190,8 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -231,6 +236,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "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=="], @@ -265,6 +272,8 @@ "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/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 4d24a4d..cc3ceac 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "src/openai-sandbox-client.ts", "src/sandbox-client.ts", "README.md", - "skills" + "schemas", + "skills", + "templates" ], "license": "MIT", "homepage": "https://xapi.to", @@ -49,6 +51,9 @@ }, "dependencies": { "@openai/agents": "0.15.0", + "acorn": "^8.18.0", + "jsonc-parser": "^3.3.1", + "smol-toml": "^1.8.0", "zod": "^4.0.0" }, "devDependencies": { diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json new file mode 100644 index 0000000..9bd7de7 --- /dev/null +++ b/schemas/worker-project.v1.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xapi.to/schemas/worker-project.v1.json", + "title": "xAPI Worker project", + "type": "object", + "additionalProperties": false, + "required": ["version", "worker", "wrangler", "build", "environments"], + "properties": { + "$schema": { + "const": "https://xapi.to/schemas/worker-project.v1.json" + }, + "version": { "const": 1 }, + "workerId": { "type": "string", "format": "uuid" }, + "worker": { + "type": "object", + "additionalProperties": false, + "required": ["name", "slug"], + "properties": { + "name": { "type": "string", "minLength": 2, "maxLength": 80 }, + "slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,47}[a-z0-9]$" + }, + "description": { "type": "string", "maxLength": 500 }, + "template": { "enum": ["worker", "agent"], "default": "worker" } + } + }, + "wrangler": { "$ref": "#/$defs/projectPath" }, + "build": { + "type": "object", + "additionalProperties": false, + "required": ["command", "output"], + "properties": { + "command": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "output": { "$ref": "#/$defs/projectPath" } + } + }, + "environments": { + "type": "object", + "additionalProperties": false, + "required": ["preview", "production"], + "properties": { + "preview": { "$ref": "#/$defs/environment" }, + "production": { "$ref": "#/$defs/environment" } + } + } + }, + "$defs": { + "projectPath": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000]+$" + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["dailyBudgetUsd"], + "properties": { + "dailyBudgetUsd": { "type": "number", "minimum": 0.1, "maximum": 100 }, + "healthCheck": { + "type": "string", + "pattern": "^/(?!/)[^\\s]*$", + "maxLength": 500, + "default": "/health" + }, + "resources": { + "type": "array", + "maxItems": 100, + "default": [], + "items": { "$ref": "#/$defs/resource" } + }, + "secrets": { + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "default": [], + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + } + } + } + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": ["type", "bindingName"], + "properties": { + "type": { + "enum": [ + "kv_namespace", + "d1_database", + "r2_bucket", + "durable_object", + "queue", + "workflow" + ] + }, + "bindingName": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "className": { + "type": "string", + "pattern": "^[A-Za-z_$][A-Za-z0-9_$]{0,127}$" + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "durable_object" } } }, + "then": { "required": ["className"] }, + "else": { "not": { "required": ["className"] } } + } + ] + } + } +} diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index c022120..af14293 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -1,7 +1,8 @@ --- name: xapi -description: Access real-time external data and managed cloud sandboxes via the xapi CLI — Twitter/X, social platforms, domain purchase and DNS, normalized crypto, BlockPI RPC, Binance Web3 API, web/news search, AI generation, SMS verification, and auditable ephemeral compute. Configure the xAPI AI or WebSocket Gateways, or use sandbox run for automatic quote/create/execute/cleanup. Use when the user mentions xapi, external services, or sandbox compute. -metadata: {"openclaw":{"emoji":"x","requires":{"anyBins":["npx"]},"primaryEnv":"XAPI_KEY"}} +description: Access real-time external data, managed cloud sandboxes, and hosted Workers via the xapi CLI — Twitter/X, social platforms, domain purchase and DNS, normalized crypto, BlockPI RPC, Binance Web3 API, web/news search, AI generation, SMS verification, and auditable compute. Configure xAPI AI or WebSocket Gateways, run ephemeral Sandbox jobs, or deploy JavaScript and persistent Agents to xAPI Workers. +metadata: + { "openclaw": { "emoji": "x", "requires": { "anyBins": ["npx"] }, "primaryEnv": "XAPI_KEY" } } --- # xapi CLI Skill @@ -23,21 +24,16 @@ Before calling any API, you need an API key: ```bash # Register a new account (apiKey is saved automatically) npx xapi-to register - # Replace an already-saved file key only when intentionally creating a new account npx xapi-to register --force - # Register with an inviter's referral code (server-side referral and promotion terms may change) # please replace xapito to your actual referral code npx xapi-to register --referral-code xapito npx xapi-to register xapito # positional shorthand - # Or set an existing key npx xapi-to config set apiKey= - # Safer for shared terminals: paste the key on stdin, then press Ctrl-D npx xapi-to config set apiKey=- - # Verify connectivity npx xapi-to config health ``` @@ -63,15 +59,18 @@ xapi offers two types of APIs under a unified interface: Both types use the same discovery and call workflow. Use `--source capability` or `--source api` on commands that expose source filtering. ## Managed Sandbox Compute -Read `guides/sandbox.md` before creating a billable instance. For a one-shot -command, prefer `sandbox run`; it quotes, applies a price ceiling, waits, -executes, and terminates in `finally`: + +Read `guides/sandbox.md` before creating a billable instance. For a one-shot command, prefer `sandbox run`; it quotes, applies a price ceiling, waits, executes, and terminates in `finally`: + ```bash npx xapi-to sandbox run --command 'python3 -c "print(6 * 7)"' ``` -Use granular commands only for multi-step work. Keep the instance ID, terminate -in cleanup, and verify terminal state/cost afterward. Do not use `--keep` unless -the user explicitly wants a reusable, continuing-to-bill instance. + +Use granular commands only for multi-step work. Keep the instance ID, terminate in cleanup, and verify terminal state/cost afterward. Do not use `--keep` unless the user explicitly wants a reusable, continuing-to-bill instance. + +## 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. ## Usage Workflow @@ -228,13 +227,13 @@ Use `--code ` with `get` or `call` to generate ready-to-use code snippet Supported targets and aliases: -| Target | Aliases | Default library | Variants | -|--------|---------|----------------|----------| -| `curl` | — | curl | — | -| `python` | `py` | requests | `python.requests`, `python.httpx`, `py.requests`, `py.httpx` | -| `javascript` | `js` | fetch | `javascript.fetch`, `javascript.axios`, `js.fetch`, `js.axios` | -| `typescript` | `ts` | fetch | `typescript.fetch`, `ts.fetch` | -| `go` | — | net/http | — | +| Target | Aliases | Default library | Variants | +| ------------ | ------- | --------------- | -------------------------------------------------------------- | +| `curl` | — | curl | — | +| `python` | `py` | requests | `python.requests`, `python.httpx`, `py.requests`, `py.httpx` | +| `javascript` | `js` | fetch | `javascript.fetch`, `javascript.axios`, `js.fetch`, `js.axios` | +| `typescript` | `ts` | fetch | `typescript.fetch`, `ts.fetch` | +| `go` | — | net/http | — | ```bash # Generate a curl command from API schema (template with empty values) @@ -333,6 +332,7 @@ When the user's task involves these workflows, read the corresponding guide file - **`guides/ai_gateway.md`** — xAPI AI Gateway: Claude Code and Anthropic/OpenAI SDK setup, model discovery, routing strategies, streaming, fallback, routing/billing headers, direct media endpoints, and known limitations - **`guides/ws_gateway.md`** — xAPI WebSocket Gateway: OpenAI Realtime, streaming ASR/TTS, simultaneous interpretation, podcast generation, service/path routing, browser authentication, native binary protocols, limits, billing, close codes, and reconnects - **`guides/sandbox.md`** — managed Sandbox compute: AI tool selection, one-shot and multi-step lifecycles, provider pinning, files, Cloudflare Web previews, suspension, GPU jobs, parallel agents, cleanup recovery, audit/history, and billing verification +- **`guides/workers.md`** — xAPI-hosted Cloudflare Workers: new-project and Wrangler import flows, plan/push/promote/rollback, no-Git and CI operation, Worker vs Sandbox selection, managed KV/D1/R2/DO/Queue/Workflow and Secrets, persistent schedules, managed DNS/TLS, Tail observability, usage settlement and hard budget/balance guards, Artifact deployment, API-key instance visibility, audit, and deletion - **`guides/sms.md`** — SMS verification: buy virtual phone numbers, receive verification codes, finish/cancel orders (5SIM) - **`guides/provider.md`** — Provider management: create/update services, About/changelog, version lifecycle, metrics/events and request receipts, Skill upload/linking, rollback/delete, earnings transfer diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md new file mode 100644 index 0000000..1dd6bea --- /dev/null +++ b/skills/xapi/guides/workers.md @@ -0,0 +1,396 @@ +# xAPI Hosted Workers + +Use this guide when the user wants to deploy an API, Webhook, Chat endpoint, scheduled JavaScript task, or persistent Agent to xAPI-managed Cloudflare Workers for Platforms. + +## Choose Worker or Sandbox + +- Use `workers` for continuously addressable HTTP/WebSocket applications, Webhooks, scheduled tasks, and persistent Agent entrypoints. The control plane currently manages KV, D1, R2, Durable Objects, Queues, Workflows, Secrets, and persistent schedules. Run `workers capabilities` before provisioning because the configured Cloudflare token may not have every required resource permission. +- Use `sandbox` for arbitrary shell commands, builds, browsers, GPU work, or short-lived isolated jobs. +- A Worker may dispatch heavy work to Sandbox. Do not keep a Sandbox alive merely to act as an HTTP service when a Worker fits. + +## Authentication and test routing + +Keys need `workers:read` for reads and `workers:write` for mutations. The CLI reads `XAPI_KEY`, then `XAPI_API_KEY`, then `~/.xapi/config.json`. + +The account owner can additionally restrict each key to all account Workers, +only Workers created by that key, or own plus selected Workers. Configure this +in Console → API Keys → Permissions. Every CLI subcommand respects the same +server-side instance boundary; an out-of-scope Worker is returned as `404`. + +Production uses `api.xapi.to`. Select the test control plane explicitly: + +```bash +export XAPI_API_HOST=test.xapi.to +``` + +Do not send the key directly to Cloudflare or any non-xAPI host. xAPI owns the Cloudflare account and API token. + +## Prefer the project workflow + +For a normal application or Agent, use the project commands instead of manually +passing Worker IDs, Artifact IDs, and idempotency keys. `xapi.worker.json` stores +only xAPI-specific desired state and the remote `workerId`; Wrangler remains the +source of truth for the entrypoint, compatibility settings, and Cloudflare-style +bindings. The file contains no credential and may be committed. + +Create a new project: + +```bash +xapi workers templates +xapi workers init my-agent --template persistent-agent +cd my-agent +xapi workers plan --env preview +xapi workers push --env preview +``` + +The templates are versioned files packaged with the CLI, so `init` neither +downloads nor executes remote code. The `persistent-agent` starter declares KV, +D1, R2, one Durable Object, one Queue, and one Workflow in both environments, +plus the Secret names `APP_TOKEN` and `MODEL_KEY`. `plan` shows the exact desired +changes; `push` creates environment-specific resources and binds their provider +IDs without writing those IDs into application source. Set secret values after +the Worker ID exists: + +```bash +xapi workers secrets set APP_TOKEN --env preview --from-env APP_TOKEN +xapi workers secrets set MODEL_KEY --env preview --from-env MODEL_KEY +``` + +The generated `TEMPLATE.md` explains the `/chat`, `/state`, `/queue`, +`/workflow`, and `/cron` routes and includes a remote smoke test. Users own and +edit `src/index.ts`; the template is only the initial project snapshot. + +Import an existing Cloudflare Worker without reusing its Cloudflare account ID, +resource IDs, routes, or secrets: + +```bash +cd existing-worker +xapi workers init --from-wrangler ./wrangler.jsonc +xapi workers plan --env preview +xapi workers push --env preview +``` + +`init --from-wrangler` also accepts `wrangler.toml`. It classifies settings as +`SUPPORTED`, `MANAGED`, `REENTER`, `IGNORED`, or `UNSUPPORTED`; it refuses to +write a partial project unless the user explicitly accepts the report with +`--accept-partial`. + +The project workflow does not require Git. Git repository, branch, and commit +are optional provenance, not authentication and not a deployment prerequisite. +It runs the configured build, creates the remote Worker when `workerId` is +absent, safely creates or updates declared resources, uploads one immutable +Artifact, deploys preview, waits for the active state, and runs the configured +health check. `push` never deletes an extra stateful resource or Secret; `plan` +marks such drift `MANUAL` for explicit handling. + +After real preview validation, promote the exact active preview Artifact without +rebuilding it: + +```bash +xapi workers promote --to production +``` + +Production promotion verifies bindings, Secret names, budgets, Artifact +identity, and health before reporting success. To restore code: + +```bash +xapi workers rollback --env production --to previous +# Or select one visible historical deployment: +xapi workers rollback --env production --deployment +``` + +Rollback restores the selected code Artifact and compatibility settings only. +It does **not** restore or migrate KV, D1, R2, Durable Objects, Queues, Workflows, +schedule state, or Secret values. Inspect application data compatibility before +confirming production rollback. + +For CI, provide a least-privilege Key scoped to the selected Worker and keep +both host and key explicit. Non-interactive mode removes prompts but does not +bypass `BLOCKED` plan items or production preflights: + +```bash +export XAPI_API_HOST=test.xapi.to +export XAPI_KEY="$CI_XAPI_KEY" +xapi workers plan --env preview --format json +xapi workers push --env preview --non-interactive +xapi workers promote --to production --non-interactive +``` + +When an operation result is uncertain, rerun the same project command. Its +stable idempotency keys and state lookup recover the existing operation rather +than publishing a duplicate. Do not change project inputs merely to force a +retry. + +Follow runtime output after deployment: + +```bash +xapi workers logs --env preview --tail --since 10m +xapi workers logs --env preview --request-id +xapi workers logs --env preview --deployment +``` + +## Advanced: granular control-plane commands + +Use the commands below for platform diagnosis, explicit resource operations, or +custom automation that cannot use `xapi.worker.json`. They are the primitives +used by the project workflow, not the recommended first-time deployment path. + +### Create with explicit budgets + +The account needs at least $5 available balance. This is a creation guard, not a prepayment. Both environment budgets are required and must be between $0.10 and $100 per day. + +```bash +npx xapi-to workers create \ + --name "Daily research agent" \ + --slug daily-research-agent \ + --template agent \ + --preview-budget 0.25 \ + --production-budget 2 \ + --format pretty +``` + +Save the returned Worker ID. Preview and production have separate script names, hostnames, budgets, bindings, and state resources. +Each environment also returns `publicUrl`. Use that field for calls: xAPI may point it at the shared dispatcher while a custom hostname is waiting for DNS and TLS. `dispatchUrl` is immediately routable through the platform Worker; `customDomainUrl` is the intended dedicated hostname and must not be presented as ready until its domain status is verified. + +### Produce an Artifact, then deploy + +Deployments consume an immutable xAPI Artifact, never a mutable directory and never a running Sandbox. The normal path is to bundle locally or in CI and upload one ES module. Sandbox is an optional build provider. + +The user's API key authenticates only xAPI control-plane requests. It is never embedded in a bundle, written into a build Sandbox, or sent directly to Cloudflare. Do not put runtime secrets in source; add them later as encrypted Secret bindings. + +```js +// src/index.ts +export default { + async fetch(_request, env) { + return Response.json({ ok: true, ai: env.XAPI_AI_BASE_URL }); + }, +}; +``` + +Build locally or in CI, then upload the single bundled UTF-8 ES module. It must be at most 1 MiB and include its runtime dependencies: + +```bash +npm run build +npx xapi-to workers upload \ + --file dist/worker.mjs \ + --idempotency-key artifact-2026-08-21 +``` + +Save the returned Artifact `id`, then deploy that exact Artifact to preview: + +```bash +npx xapi-to workers deploy \ + --artifact \ + --env preview \ + --compatibility-date 2026-08-21 \ + --idempotency-key release-candidate-1 +``` + +After deployment, read the environment `publicUrl` instead of constructing a hostname: + +```bash +npx xapi-to workers get --format pretty +curl "/health" +``` + +### Optional Sandbox build + +`workers build` uploads a bounded project snapshot. The control plane creates an ephemeral Sandbox with platform credentials, writes the project, executes the explicit command, stores the output as the same immutable Artifact type, and requests termination in `finally`. + +The CLI skips `.git`, `.xapi`, `node_modules`, `dist`, credential directories (`.ssh`, `.aws`, `.gnupg`, `.docker`), `.env*`, package-manager credential files, private-key extensions, and common SSH private-key names. + +`--output` must identify the single bundled ES module produced by the command: + +```bash +npx xapi-to workers build \ + --project . \ + --entrypoint src/index.ts \ + --command "npm install --ignore-scripts --no-audit --no-fund && npm run build" \ + --output dist/worker.mjs \ + --idempotency-key source-2026-08-21 +``` + +The result must have `status: SUCCEEDED`. Save its `artifactId`, not the build `id`, and deploy it with `--artifact`. + +Reuse an upload key only for identical bundle bytes. Reuse a build key only for the exact same source snapshot and parameters. Reuse a deployment key only for the same Artifact and environment. A successful deployment returns `status: ACTIVE`; `DEPLOYING` is not completion and `FAILED` must be surfaced with its error. + +After real preview validation, deploy the identical file to production with a new stable key: + +```bash +npx xapi-to workers deploy \ + --artifact \ + --env production \ + --idempotency-key production-2026-08-21 +``` + +Workers for Platforms switches User Worker uploads all at once. Treat preview validation as a release gate; do not imply gradual rollout. + +## Attach isolated Cloudflare resources + +Managed resources belong to one Worker environment. Create separate preview and production resources even when their binding names match. User code sees the binding through `env.`; it never receives the xAPI Cloudflare account or API token. + +```bash +# Inspect the active provider permissions first. A failed item names the exact +# Cloudflare permission that the platform operator must add. +npx xapi-to workers capabilities --format pretty + +# Durable key/value state +npx xapi-to workers resources create \ + --env preview --type kv --binding STATE + +# SQL and object storage +npx xapi-to workers resources create \ + --env preview --type d1 --binding DB +npx xapi-to workers resources create \ + --env preview --type r2 --binding FILES + +# Stateful Agent coordination, asynchronous work, and durable multi-step jobs +npx xapi-to workers resources create \ + --env preview --type do --binding AGENT_STATE --class-name AgentState +npx xapi-to workers resources create \ + --env preview --type queue --binding TASK_QUEUE +npx xapi-to workers resources create \ + --env preview --type workflow --binding AGENT_WORKFLOW + +npx xapi-to workers resources list \ + --env preview --format table +``` + +After creating or deleting a resource, deploy the Worker again so the new binding set becomes active. Durable Object creation needs the exported `--class-name`; xAPI adds its migration during deployment. Queue and Workflow resources are isolated per environment and are exposed only through their declared binding. Treat an `ERROR` resource as unavailable and surface its Cloudflare permission or provisioning error; do not deploy code that assumes it exists. + +xAPI Queue creation includes the producer binding, an isolated Cloudflare Queue, +and an xAPI-managed consumer. The User Worker sends a route envelope; the +managed consumer delivers it back to the same Worker environment over an +internal Cloudflare Service Binding: + +```js +await env.TASK_QUEUE.send({ + path: "/tasks/summarize", + method: "POST", + body: { taskId: "task_123", objectKey: "uploads/report.pdf" }, +}); +``` + +`path` must be a local absolute path. Supported methods are `GET`, `POST`, +`PUT`, `PATCH`, and `DELETE`; an omitted path uses `/__xapi/queue`. Delivery is +at least once: a successful `2xx` response is acknowledged, while a failure is +retried. Tell users to make the target route idempotent by stable task ID and +to pass resource identifiers instead of secrets in the message body. + +Workflow creation installs a managed Workflow host. User code starts an +instance with `env.AGENT_WORKFLOW.create({ params: { path, method, body } })`, +saves the returned ID, and calls +`(await env.AGENT_WORKFLOW.get(id)).status()` until `complete`, `errored`, or +`terminated`. A returned instance ID means accepted, not completed. + +```js +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/tasks/summarize") { + const task = await request.json(); + // Check task.taskId before executing so Queue retries are harmless. + return Response.json({ completed: true, taskId: task.taskId }); + } + await env.STATE.put("last-request", new Date().toISOString()); + const rows = await env.DB.prepare("SELECT id, title FROM tasks").all(); + await env.FILES.put("latest.json", JSON.stringify(rows.results)); + return Response.json({ ok: true, tasks: rows.results }); + }, +}; +``` + +D1 tables still require an application migration or explicit initialization query. Resource creation does not invent the user's schema. R2 buckets must be empty before Cloudflare allows deletion. + +Do not treat resource creation alone as validation. After redeploying, perform +a write/read round trip for a Durable Object, send a Queue message and observe +the target route's durable result, and start a Workflow then poll it to a +terminal state. + +## Run persistent schedules + +xAPI stores schedules in the control plane rather than inside a short-lived Sandbox. It evaluates the cron expression in the declared IANA timezone, leases each run to prevent duplicate execution across replicas, retries failures up to the configured limit, and keeps run history. + +```bash +npx xapi-to workers schedules create \ + --name "refresh research digest" \ + --cron "0 */6 * * *" \ + --timezone Asia/Shanghai \ + --env production \ + --path /tasks/refresh \ + --method POST \ + --body '{"source":"scheduled"}' + +npx xapi-to workers schedules list --format table +npx xapi-to workers schedules run +npx xapi-to workers schedules runs --format table +npx xapi-to workers schedules pause +npx xapi-to workers schedules resume +``` + +An immediate run exercises the same lease, retry, audit, budget, and Worker route as a cron run. Use it as the release check before enabling production schedules. A schedule is persistent metadata; it does not mean a Worker process stays alive between requests. + +### Encrypted Secrets + +Prefer `--from-env` so plaintext does not appear in shell history. The control plane encrypts the value at rest and public reads expose only binding name, version, and timestamps. When a script is already active, rotation is applied immediately; otherwise it is applied during the next deployment. + +```bash +export MODEL_KEY='...' +npx xapi-to workers secrets set MODEL_KEY \ + --env preview --from-env MODEL_KEY +npx xapi-to workers secrets list --env preview +unset MODEL_KEY +``` + +Never print the value to verify it. User code can report only whether a secret is configured. Delete explicitly when no longer needed: + +```bash +npx xapi-to workers secrets delete MODEL_KEY \ + --env preview --yes +``` + +## Inspect and manage + +```bash +npx xapi-to workers list --format table +npx xapi-to workers get --format pretty +npx xapi-to workers audit --format table +npx xapi-to workers invocations --env preview --format table +npx xapi-to workers logs --env preview --format table +npx xapi-to workers usage --env preview --format pretty +npx xapi-to workers billing-status --format pretty +npx xapi-to workers domains list --format table +npx xapi-to workers artifacts --format table +npx xapi-to workers builds --format table +npx xapi-to workers bindings --format table +npx xapi-to workers resources list --env preview --format table +npx xapi-to workers secrets list --env preview --format table +npx xapi-to workers provider-status +npx xapi-to workers capabilities --format table +npx xapi-to workers artifact-provider-status +npx xapi-to workers build-provider-status +npx xapi-to workers budget preview --daily-usd 0.50 +``` + +`invocations` shows request metadata and aggregate performance. `logs` reads Tail Worker console messages, exceptions, and traces; request/response bodies, headers, and query strings are deliberately excluded. `usage` shows authorization reservations, actual Tail-settled CPU charges, refunds, and the Cloudflare GraphQL reconciliation gap. + +The dispatcher preauthorizes the maximum per-request charge before executing user code. It rejects exhausted account/API-key balances and environment daily budgets before dispatch; Tail telemetry settles actual CPU and refunds the unused reservation. If billing authorization is unavailable while enforcement is enabled, execution fails closed. + +Each environment receives an exact managed hostname. Use `publicUrl` immediately; it falls back to the shared dispatcher until the dedicated hostname reaches `ACTIVE`. The control plane attaches a Cloudflare Worker Custom Domain, waits for DNS and TLS, and detaches it during Worker deletion. On `ERROR`, inspect the recorded reason and retry explicitly: + +```bash +npx xapi-to workers domains list --format pretty +npx xapi-to workers domains retry +``` + +Bindings are risk-tiered. A catalog entry describes product policy; `workers capabilities` is the live Cloudflare token preflight. A failed D1 item, for example, must identify `D1 Edit` and block D1 creation while leaving unrelated resources usable. + +## Delete safely + +Only delete when the user asked for it. The CLI requires explicit confirmation: + +```bash +npx xapi-to workers delete --yes +``` + +The backend preflights managed resources (for example, R2 must be empty), deletes active upstream scripts and resources, then marks the Worker soft-deleted. Records have a 30-day retention window. A partial upstream failure leaves the Worker in `DELETING`; report the error and do not say the Worker is deleted or active. diff --git a/src/commands/workers.ts b/src/commands/workers.ts new file mode 100644 index 0000000..db4ad6d --- /dev/null +++ b/src/commands/workers.ts @@ -0,0 +1,1296 @@ +import { randomUUID } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { XAPI_API_HOST, getConfig, requireApiKey } from "../config.ts"; +import { err, output, type OutputFormat } from "../format.ts"; +import * as client from "../workers-client.ts"; +import { + initWorkerProject, + type WorkerStarterTemplate, +} from "../workers-init.ts"; +import { listWorkerTemplates } from "../workers-templates.ts"; +import { importWranglerProject } from "../workers-wrangler-import.ts"; +import { createWorkerPlan } from "../workers-plan.ts"; +import { + formatWorkerPlan, + useHumanWorkerPlanOutput, +} from "../workers-plan-output.ts"; +import { pushWorkerProject, WorkerPushError } from "../workers-push.ts"; +import { + formatWorkerPushResult, + useHumanWorkerPushOutput, +} from "../workers-push-output.ts"; +import { promoteWorkerProject } from "../workers-promote.ts"; +import { rollbackWorkerProject } from "../workers-rollback.ts"; +import { readWorkerLogs, tailWorkerLogs } from "../workers-logs.ts"; +import { formatWorkerMetering } from "../workers-metering-output.ts"; +import { + printWorkerBillingResponse, + workerBillingOutputMode, +} from "../workers-billing-output.ts"; + +export const WORKERS_HELP = `xapi-to workers - Deploy and manage xAPI-hosted Cloudflare Workers + +USAGE + xapi-to workers [args] [flags] + +COMMANDS + templates + init [directory] --template TEMPLATE + plan --env preview|production + push --env preview + promote --to production [--artifact ARTIFACT_ID] + rollback --env preview|production (--to previous | --deployment DEPLOYMENT_ID) + list + get + create --name NAME --slug SLUG --preview-budget USD --production-budget USD + upload --file dist/index.mjs + artifacts + build --project . --entrypoint src/index.ts --command "npm run build" + builds + deploy --artifact ARTIFACT_ID --env preview|production + budget --daily-usd USD + audit + invocations --env preview|production + logs --env preview|production [--tail] [--since 10m] + usage [--env preview|production] + metering --env preview|production [--json] + billing-status + retention show|quote|accept|pause|resume|keep-paused|delete --env ENV + billing prices|overview|usage|ledger|forecast|risk|lifecycle --env preview|production + domains list + domains retry + schedules list + schedules create --name NAME --cron "*/15 * * * *" --env preview --path /cron + schedules runs + schedules run + schedules pause|resume + schedules delete --yes + bindings + resources list --env preview|production + resources create --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME + resources delete --env ENV --yes + secrets list --env preview|production + secrets set --env ENV --from-env VARIABLE + secrets delete --env ENV --yes + provider-status + capabilities + artifact-provider-status + build-provider-status + delete --yes + +CREATE FLAGS + --template worker|agent Official starter type (default: worker) + --description TEXT + --preview-budget 0.10..100 Explicit preview daily budget + --production-budget 0.10..100 Explicit production daily budget + +INIT FLAGS + --template TEMPLATE worker|agent|chat|webhook|persistent-agent + --from-wrangler PATH Import an existing wrangler.jsonc or wrangler.toml + --accept-partial Write only after explicitly accepting unsupported fields + --name NAME Worker display name + --slug SLUG Stable lowercase Worker slug + --preview-budget 0.10..100 Default: 0.25 + --production-budget 0.10..100 Default: 2 + --force Overwrite template-managed files only + +PLAN FLAGS + --env preview|production Environment to compare (required) + --config PATH Explicit xapi.worker.json path + Interactive terminals show a review view by default; use --format json for CI + +PUSH FLAGS + --env preview Required; production uses workers promote + --config PATH Explicit xapi.worker.json path + --non-interactive CI mode; never bypasses BLOCKED checks + --retention-price-version VERSION Explicit accepted freeze quote; does not auto-accept policy + +PROMOTE FLAGS + --to production Required explicit production target + --artifact ARTIFACT_ID ACTIVE preview Artifact (default: latest) + --config PATH Explicit xapi.worker.json path + --non-interactive CI mode after all production preflights pass + +ROLLBACK FLAGS + --env preview|production Environment whose code will be rolled back + --to previous Select the latest different successful version + --deployment DEPLOYMENT_ID Select an explicit historical deployment + --config PATH Explicit xapi.worker.json path + --non-interactive Explicit CI confirmation for production + +LOG FLAGS + --tail Poll continuously; Ctrl-C stops cleanly + --since 30s|10m|2h Include only recent log events + --level debug|info|log|warn|error Filter by exact log level + --request-id ID Filter one xAPI request trace + --deployment DEPLOYMENT_ID Filter by the derived deployment timeline + +BILLING FLAGS + --env preview|production Environment to inspect (required) + --json Emit the public API schema unchanged + --snapshot-time ISO Reuse one coherent Backend snapshot + --from ISO --to ISO Usage range on five-minute boundaries + --metric NAME --resource-id ID Filter usage or ledger + --cursor OPAQUE --limit 1..100 Page ledger without decoding its cursor + +UPLOAD FLAGS + --file PATH Bundled UTF-8 ES module (required, max 1 MiB) + --idempotency-key KEY Stable retry key (generated when omitted) + +OPTIONAL SANDBOX BUILD FLAGS + --project PATH Project directory (default: current directory) + --entrypoint PATH Source entrypoint inside the project (required) + --command COMMAND Sandbox build command (required) + --output PATH Bundled ES module path (default: dist/index.mjs) + --idempotency-key KEY Stable retry key (generated when omitted) + +DEPLOY FLAGS + --artifact ARTIFACT_ID Immutable uploaded or built artifact (required) + --env preview|production Target environment (default: preview) + --compatibility-date YYYY-MM-DD + --compatibility-flags a,b + --idempotency-key KEY Stable retry key (generated when omitted) + +RESOURCE FLAGS + --env preview|production Resource environment (required) + --type kv|d1|r2|do|queue|workflow + --class-name NAME Exported class for a Durable Object + --binding NAME Uppercase env binding, for example STATE or FILES + +SECRET FLAGS + --from-env VARIABLE Read value from a local environment variable + --value VALUE Direct value (prefer --from-env to avoid shell history) + +AUTHORIZATION + API keys need workers:read for reads and workers:write for mutations. + XAPI_KEY overrides XAPI_API_KEY and ~/.xapi/config.json. + +EXAMPLES + xapi-to workers templates + xapi-to workers init my-agent --template persistent-agent + xapi-to workers init --from-wrangler ./wrangler.jsonc + xapi-to workers plan --env preview --format json + xapi-to workers push --env preview + xapi-to workers promote --to production + xapi-to workers rollback --env production --to previous + xapi-to workers create --name "Daily agent" --slug daily-agent \ + --preview-budget 0.25 --production-budget 2 + xapi-to workers upload --file dist/index.mjs + xapi-to workers deploy --artifact --env preview + xapi-to workers billing overview --env production + xapi-to workers billing usage --env production --json + xapi-to workers build --entrypoint src/index.ts --command "npm run build" + xapi-to workers resources create --env preview --type kv --binding STATE + DEEPSEEK_KEY=... xapi-to workers secrets set MODEL_KEY \ + --env preview --from-env DEEPSEEK_KEY + xapi-to workers list --format table +`; + +const COMMON_FLAGS = new Set(["help", "format"]); + +function options() { + const cfg = getConfig(); + requireApiKey(cfg); + return { apiHost: XAPI_API_HOST, apiKey: cfg.apiKey! }; +} + +function printWorkerPlan( + plan: Awaited>, + flagFormat?: string, +) { + if ( + useHumanWorkerPlanOutput({ + flagFormat, + envFormat: process.env.XAPI_OUTPUT, + stdoutIsTTY: process.stdout.isTTY, + }) + ) { + console.log(formatWorkerPlan(plan)); + return; + } + output(plan, flagFormat as OutputFormat | undefined); +} + +function printWorkerPushResult( + result: Awaited>, + flagFormat?: string, +) { + if ( + useHumanWorkerPushOutput({ + flagFormat, + envFormat: process.env.XAPI_OUTPUT, + stdoutIsTTY: process.stdout.isTTY, + }) + ) { + console.log(formatWorkerPushResult(result)); + return; + } + output(result, flagFormat as OutputFormat | undefined); +} + +function required(value: string | undefined, flag: string): string { + if (!value || value === "true") err(`${flag} is required`); + return value; +} + +function budget(value: string | undefined, flag: string): number { + const amount = Number(required(value, flag)); + if (!Number.isFinite(amount) || amount < 0.1 || amount > 100) { + err(`${flag} must be between 0.10 and 100`); + } + return amount; +} + +function assertFlags( + flags: Record, + allowed: readonly string[] = [], +): void { + const valid = new Set([...COMMON_FLAGS, ...allowed]); + const unknown = Object.keys(flags).filter((flag) => !valid.has(flag)); + if (unknown.length) { + err( + `unknown workers flag: ${unknown.map((flag) => `--${flag}`).join(", ")}`, + { + validFlags: [...valid].sort().map((flag) => `--${flag}`), + }, + ); + } +} + +function oneId(args: string[], usage: string): string { + if (args.length !== 1) err(usage); + return args[0]; +} + +function environment(value: string | undefined): string { + const result = required(value, "--env"); + if (!["preview", "production"].includes(result)) { + err("--env must be preview or production"); + } + return result; +} + +const IGNORED_DIRECTORIES = new Set([ + ".aws", + ".docker", + ".git", + ".gnupg", + ".ssh", + ".xapi", + "dist", + "node_modules", +]); +const SECRET_FILE = + /^(?:\.env(?:\..*)?|\.git-credentials|\.netrc|\.npmrc|\.yarnrc(?:\..*)?|.*\.(?:pem|key|p12|pfx)|id_(?:rsa|ecdsa|ed25519))$/i; + +async function projectFiles(project: string) { + const files: Array<{ + path: string; + content: string; + encoding: "utf8" | "base64"; + }> = []; + let total = 0; + async function walk(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue; + if (SECRET_FILE.test(entry.name)) continue; + const absolute = resolve(directory, entry.name); + if (entry.isDirectory()) { + await walk(absolute); + continue; + } + if (!entry.isFile()) continue; + const info = await stat(absolute); + if (info.size > 1_000_000) err(`source file exceeds 1 MB: ${absolute}`); + const buffer = await readFile(absolute); + total += buffer.length; + if (total > 2 * 1024 * 1024) + err("Worker project exceeds the 2 MB source limit"); + if (files.length >= 200) err("Worker project exceeds the 200 file limit"); + const path = relative(project, absolute).split(sep).join("/"); + const binary = buffer.includes(0); + files.push({ + path, + content: binary ? buffer.toString("base64") : buffer.toString("utf8"), + encoding: binary ? "base64" : "utf8", + }); + } + } + await walk(project); + return files; +} + +async function bundledModule(path: string) { + const absolute = resolve(path); + const info = await stat(absolute).catch((error) => { + err(`cannot read Worker bundle: ${absolute}`, error.message); + }); + if (!info.isFile()) err(`Worker bundle is not a file: ${absolute}`); + if (!info.size) err("Worker bundle is empty"); + if (info.size > 1024 * 1024) + err("Worker bundle exceeds the 1 MiB artifact limit"); + const buffer = await readFile(absolute); + const moduleCode = buffer.toString("utf8"); + if (!Buffer.from(moduleCode, "utf8").equals(buffer)) { + err("Worker bundle must be valid UTF-8 JavaScript"); + } + return moduleCode; +} + +export async function workersCommand( + args: string[], + flags: Record, +): Promise { + if (flags.help || args.length === 0) { + console.log(WORKERS_HELP); + return; + } + const [command, ...rest] = args; + switch (command) { + case "templates": { + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers templates"); + output(listWorkerTemplates()); + return; + } + case "init": { + assertFlags(flags, [ + "template", + "from-wrangler", + "accept-partial", + "name", + "slug", + "preview-budget", + "production-budget", + "force", + ]); + if (rest.length > 1) { + err("usage: xapi-to workers init [directory] [flags]"); + } + if (Object.hasOwn(flags, "from-wrangler")) { + if (!flags["from-wrangler"] || flags["from-wrangler"] === "true") { + err("--from-wrangler requires a .jsonc, .json, or .toml path"); + } + if (rest.length || flags.template || flags.name || flags.slug) { + err( + "--from-wrangler cannot be combined with a target directory, --template, --name, or --slug", + ); + } + if (flags["accept-partial"] && flags["accept-partial"] !== "true") { + err("--accept-partial does not accept a value"); + } + if (flags.force && flags.force !== "true") { + err("--force does not accept a value"); + } + let result; + try { + result = importWranglerProject({ + wranglerPath: flags["from-wrangler"], + acceptPartial: flags["accept-partial"] === "true", + force: flags.force === "true", + previewDailyBudgetUsd: flags["preview-budget"] + ? budget(flags["preview-budget"], "--preview-budget") + : 0.25, + productionDailyBudgetUsd: flags["production-budget"] + ? budget(flags["production-budget"], "--production-budget") + : 2, + }); + } catch (error) { + err( + error instanceof Error + ? error.message + : "Unable to import Wrangler project", + ); + } + output(result); + if (!result.wrote) { + err( + "Wrangler import contains unsupported fields; no files were written", + ); + } + return; + } + if (flags["accept-partial"]) { + err("--accept-partial is only valid with --from-wrangler"); + } + const template = (flags.template || "worker") as WorkerStarterTemplate; + if (flags.force && flags.force !== "true") { + err("--force does not accept a value"); + } + try { + output( + initWorkerProject({ + target: rest[0] || ".", + template, + name: flags.name === "true" ? undefined : flags.name, + slug: flags.slug === "true" ? undefined : flags.slug, + previewDailyBudgetUsd: flags["preview-budget"] + ? budget(flags["preview-budget"], "--preview-budget") + : 0.25, + productionDailyBudgetUsd: flags["production-budget"] + ? budget(flags["production-budget"], "--production-budget") + : 2, + force: flags.force === "true", + }), + ); + } catch (error) { + err( + error instanceof Error + ? error.message + : "Unable to initialize Worker project", + ); + } + return; + } + case "plan": { + assertFlags(flags, ["env", "config"]); + if (rest.length) err("usage: xapi-to workers plan --env ENV"); + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + const plan = await createWorkerPlan({ + environment: environment(flags.env) as "preview" | "production", + configPath: flags.config, + clientOptions: options(), + }); + printWorkerPlan(plan, flags.format); + return; + } + case "retention": { + assertFlags(flags, ["env", "type", "price-version", "yes"]); + const [action, id, ...extra] = rest; + if (!id || extra.length || !["show", "quote", "accept", "pause", "resume", "keep-paused", "delete"].includes(action)) err("usage: workers retention show|quote|accept|pause|resume|keep-paused|delete --env ENV"); + const env = environment(flags.env); + if (["accept", "delete"].includes(action) && flags.yes !== "true") err("--yes is required to accept automatic reserve-exhaustion deletion or delete this environment"); + const result = await client.workerRetention(options(), id, env, + action === "quote" ? `/quote/${encodeURIComponent(flags.type || "WORKER")}` : action === "accept" ? "/accept" : action === "show" ? "" : "/actions", + action === "accept" ? { policyVersion: "retention-v3", automaticDeletionAccepted: true, priceVersion: required(flags["price-version"], "--price-version") } : ["show", "quote"].includes(action) ? undefined : { action }); + if (flags.format === "json") output(result); + else { + const state = result.lifecycle || result; + console.log(`Worker ${id} · ${env}\nState: ${state.state || (result.enabled === false ? "retention disabled" : "quote / policy")}`); + for (const [label, key] of [["Available balance", "availableBalanceUsd"], ["Freeze quote", "freezeUsd"], ["Available after freeze (estimate)", "availableAfterFreezeUsd"], ["Reserve target", "targetUsd"], ["Reserve remaining", "remainingUsd"], ["Reserve consumed", "consumedUsd"], ["Reserve released", "releasedUsd"]]) { + if (result[key] != null) console.log(`${label}: $${result[key]}`); + } + if (result.priceVersion) console.log(`Price version: ${result.priceVersion}`); + if (result.retentionHours) console.log(`Retention: ${result.retentionHours} hours. Automatic deletion at expiry.`); + if (result.estimateBasisHours) console.log(`Freeze estimate basis: ${result.estimateBasisHours} hours (not a fixed retention period).`); + if (result.policyVersion === "retention-v3" || state.retentionPolicyVersion === "retention-v3") { + console.log("Manual recovery only. Deposits do not replenish reserve or resume execution. Cleanup starts when this environment reserve reaches its cleanup allowance, even if the account has available funds."); + if (result.reserveBudget) console.log(`Remaining for retention: $${result.reserveBudget.retentionSpendableUsd ?? "unknown"}; cleanup allowance: $${result.reserveBudget.cleanupReserveUsd ?? "unknown"}`); + } + if (state.pauseReason) console.log(`Pause reason: ${state.pauseReason}`); + if (result.fundingSource) console.log(`Funding: ${result.fundingSource}`); + if (state.graceDeadlineAt) console.log(`Deletion deadline: ${state.graceDeadlineAt}`); + console.log("Frozen funds remain yours; freezing is not a consumption charge. Use --format json for full evidence."); + } + return; + } + case "push": { + assertFlags(flags, ["env", "config", "non-interactive", "retention-price-version"]); + if (flags["retention-price-version"] === "true" || flags["retention-price-version"] === "") { + err("--retention-price-version requires the explicitly accepted quote version"); + } + if (rest.length) err("usage: xapi-to workers push --env preview"); + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + if (flags["non-interactive"] && flags["non-interactive"] !== "true") { + err("--non-interactive does not accept a value"); + } + const selectedEnvironment = environment(flags.env); + if (selectedEnvironment !== "preview") { + err( + "workers push only accepts --env preview; use workers promote for production", + ); + } + const nonInteractive = flags["non-interactive"] === "true"; + try { + const result = await pushWorkerProject({ + environment: "preview", + configPath: flags.config, + clientOptions: options(), + nonInteractive, + retentionPriceVersion: flags["retention-price-version"], + onPlan: nonInteractive + ? undefined + : (plan) => printWorkerPlan(plan, flags.format), + }); + printWorkerPushResult(result, flags.format); + } catch (error) { + if (error instanceof WorkerPushError) { + err(error.message, error.recovery); + } + err(error instanceof Error ? error.message : "Preview push failed"); + } + return; + } + case "promote": { + assertFlags(flags, ["to", "artifact", "config", "non-interactive"]); + if (rest.length) err("usage: xapi-to workers promote --to production"); + if (flags.to !== "production") { + err("workers promote requires --to production"); + } + if (flags.artifact === "true" || flags.artifact === "") { + err("--artifact requires an Artifact ID"); + } + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + if (flags["non-interactive"] && flags["non-interactive"] !== "true") { + err("--non-interactive does not accept a value"); + } + const nonInteractive = flags["non-interactive"] === "true"; + try { + output( + await promoteWorkerProject({ + to: "production", + artifactId: flags.artifact, + configPath: flags.config, + clientOptions: options(), + nonInteractive, + onPlan: nonInteractive ? undefined : (plan) => output(plan), + }), + ); + } catch (error) { + if (error instanceof WorkerPushError) { + err(error.message, error.recovery); + } + err( + error instanceof Error + ? error.message + : "Production promotion failed", + ); + } + return; + } + case "rollback": { + assertFlags(flags, [ + "env", + "to", + "deployment", + "config", + "non-interactive", + ]); + if (rest.length) { + err( + "usage: xapi-to workers rollback --env ENV (--to previous | --deployment DEPLOYMENT_ID)", + ); + } + const selectedEnvironment = environment(flags.env) as + | "preview" + | "production"; + if (flags.to && flags.to !== "previous") { + err("--to currently supports only previous"); + } + if (flags.deployment === "true" || flags.deployment === "") { + err("--deployment requires a Deployment ID"); + } + if ((flags.to === "previous") === !!flags.deployment) { + err( + "choose exactly one of --to previous or --deployment DEPLOYMENT_ID", + ); + } + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + if (flags["non-interactive"] && flags["non-interactive"] !== "true") { + err("--non-interactive does not accept a value"); + } + const nonInteractive = flags["non-interactive"] === "true"; + try { + output( + await rollbackWorkerProject({ + environment: selectedEnvironment, + to: flags.to === "previous" ? "previous" : undefined, + deploymentId: flags.deployment, + configPath: flags.config, + clientOptions: options(), + nonInteractive, + onPlan: nonInteractive ? undefined : (plan) => output(plan), + }), + ); + } catch (error) { + if (error instanceof WorkerPushError) { + err(error.message, error.recovery); + } + err(error instanceof Error ? error.message : "Worker rollback failed"); + } + return; + } + case "list": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers list"); + output(await client.listWorkers(options())); + return; + case "get": + assertFlags(flags); + output( + await client.getWorker( + options(), + oneId(rest, "usage: xapi-to workers get "), + ), + ); + return; + case "create": { + assertFlags(flags, [ + "name", + "slug", + "description", + "template", + "preview-budget", + "production-budget", + ]); + if (rest.length) err("usage: xapi-to workers create [flags]"); + const template = flags.template || "worker"; + if (!["worker", "agent"].includes(template)) + err("--template must be worker or agent"); + output( + await client.createWorker(options(), { + name: required(flags.name, "--name"), + slug: required(flags.slug, "--slug"), + description: flags.description, + template, + previewDailyBudgetUsd: budget( + flags["preview-budget"], + "--preview-budget", + ), + productionDailyBudgetUsd: budget( + flags["production-budget"], + "--production-budget", + ), + }), + ); + return; + } + case "upload": { + assertFlags(flags, ["file", "idempotency-key"]); + const id = oneId( + rest, + "usage: xapi-to workers upload --file PATH", + ); + output( + await client.uploadWorkerArtifact(options(), id, { + moduleCode: await bundledModule(required(flags.file, "--file")), + idempotencyKey: flags["idempotency-key"] || randomUUID(), + }), + ); + return; + } + case "artifacts": + assertFlags(flags); + output( + await client.listWorkerArtifacts( + options(), + oneId(rest, "usage: xapi-to workers artifacts "), + ), + ); + return; + case "build": { + assertFlags(flags, [ + "project", + "entrypoint", + "command", + "output", + "idempotency-key", + ]); + const id = oneId( + rest, + "usage: xapi-to workers build --entrypoint PATH --command COMMAND", + ); + const project = resolve(flags.project || "."); + const entrypoint = required(flags.entrypoint, "--entrypoint").replace( + /^\.\//, + "", + ); + const files = await projectFiles(project).catch((error) => { + err(`cannot read Worker project: ${project}`, error.message); + }); + output( + await client.createWorkerBuild(options(), id, { + files, + entrypoint, + buildCommand: required(flags.command, "--command"), + outputPath: (flags.output || "dist/index.mjs").replace(/^\.\//, ""), + idempotencyKey: flags["idempotency-key"] || randomUUID(), + }), + ); + return; + } + case "builds": + assertFlags(flags); + output( + await client.listWorkerBuilds( + options(), + oneId(rest, "usage: xapi-to workers builds "), + ), + ); + return; + case "deploy": { + assertFlags(flags, [ + "artifact", + "env", + "compatibility-date", + "compatibility-flags", + "idempotency-key", + "retention-price-version", + ]); + const id = oneId( + rest, + "usage: xapi-to workers deploy --artifact ARTIFACT_ID [--env preview]", + ); + const environment = flags.env || "preview"; + if (!["preview", "production"].includes(environment)) + err("--env must be preview or production"); + output( + await client.deployWorker(options(), id, { + environment, + artifactId: required(flags.artifact, "--artifact"), + retentionPriceVersion: flags["retention-price-version"], + idempotencyKey: flags["idempotency-key"] || randomUUID(), + compatibilityDate: flags["compatibility-date"], + compatibilityFlags: flags["compatibility-flags"] + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean), + }), + ); + return; + } + case "budget": { + assertFlags(flags, ["daily-usd"]); + if (rest.length !== 2) + err( + "usage: xapi-to workers budget --daily-usd USD", + ); + if (!["preview", "production"].includes(rest[1])) + err("environment must be preview or production"); + output( + await client.updateWorkerBudget( + options(), + rest[0], + rest[1], + budget(flags["daily-usd"], "--daily-usd"), + ), + ); + return; + } + case "audit": + assertFlags(flags); + output( + await client.workerAuditLogs( + options(), + oneId(rest, "usage: xapi-to workers audit "), + ), + ); + return; + case "invocations": + assertFlags(flags, ["env"]); + output( + await client.workerInvocationLogs( + options(), + oneId( + rest, + "usage: xapi-to workers invocations --env ENV", + ), + environment(flags.env), + ), + ); + return; + case "logs": { + assertFlags(flags, [ + "env", + "tail", + "since", + "level", + "request-id", + "deployment", + ]); + const id = oneId( + rest, + "usage: xapi-to workers logs --env ENV [--tail] [filters]", + ); + const selectedEnvironment = environment(flags.env) as + | "preview" + | "production"; + if (flags.tail && flags.tail !== "true") { + err("--tail does not accept a value"); + } + for (const flag of ["since", "level", "request-id", "deployment"]) { + if (flags[flag] === "true" || flags[flag] === "") { + err(`--${flag} requires a value`); + } + } + const query = { + workerId: id, + environment: selectedEnvironment, + clientOptions: options(), + since: flags.since, + level: flags.level, + requestId: flags["request-id"], + deploymentId: flags.deployment, + }; + if (flags.tail === "true") { + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + try { + await tailWorkerLogs({ + ...query, + signal: controller.signal, + onBatch: (batch) => output(batch), + onTransientError: () => + console.error( + JSON.stringify({ + warning: "Worker log poll failed; retrying", + }), + ), + }); + } finally { + process.removeListener("SIGINT", stop); + } + } else { + output(await readWorkerLogs(query)); + } + return; + } + case "usage": + assertFlags(flags, ["env"]); + output( + await client.workerUsage( + options(), + oneId(rest, "usage: xapi-to workers usage [--env ENV]"), + flags.env ? environment(flags.env) : undefined, + ), + ); + return; + case "billing-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers billing-status"); + output(await client.workerBillingStatus(options())); + return; + case "metering": { + assertFlags(flags, ["env", "json"]); + let mode; + try { mode = workerBillingOutputMode(flags); } + catch (error) { err(error instanceof Error ? error.message : String(error)); } + const response = await client.workerMeteredUsage(options(), oneId(rest, "usage: xapi-to workers metering --env ENV [--json]"), environment(flags.env)); + if (mode === "json") output(response); + else console.log(formatWorkerMetering(response)); + return; + } + case "billing": { + const [kindValue, ...billingArgs] = rest; + const kinds = [ + "prices", + "overview", + "usage", + "ledger", + "forecast", + "risk", + "lifecycle", + ] as const; + if (!kinds.includes(kindValue as (typeof kinds)[number])) { + err( + "usage: xapi-to workers billing prices|overview|usage|ledger|forecast|risk|lifecycle --env ENV", + ); + } + const kind = kindValue as (typeof kinds)[number]; + const allowed = ["env", "json"]; + if (kind === "overview") allowed.push("snapshot-time"); + if (kind === "usage") { + allowed.push("snapshot-time", "from", "to", "metric", "resource-id"); + } + if (kind === "ledger") { + allowed.push( + "snapshot-time", + "cursor", + "limit", + "metric", + "resource-id", + ); + } + assertFlags(flags, allowed); + let mode; + try { + mode = workerBillingOutputMode(flags); + } catch (error) { + err(error instanceof Error ? error.message : String(error)); + } + if (flags.limit) { + const limit = Number(flags.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + err("--limit must be an integer from 1 to 100"); + } + } + for (const flag of [ + "snapshot-time", + "from", + "to", + "metric", + "resource-id", + "cursor", + ]) { + if (flags[flag] === "true") err(`--${flag} requires a value`); + } + const response = await client.workerBillingQuery( + options(), + oneId( + billingArgs, + `usage: xapi-to workers billing ${kind} --env ENV`, + ), + environment(flags.env), + kind, + { + snapshotTime: flags["snapshot-time"], + from: flags.from, + to: flags.to, + metric: flags.metric, + resourceId: flags["resource-id"], + cursor: flags.cursor, + limit: flags.limit, + }, + ); + printWorkerBillingResponse(kind, response, mode); + return; + } + case "domains": { + const [action, ...domainArgs] = rest; + assertFlags(flags); + if (action === "list") { + output( + await client.listWorkerDomains( + options(), + oneId( + domainArgs, + "usage: xapi-to workers domains list ", + ), + ), + ); + return; + } + if (action === "retry") { + if (domainArgs.length !== 2) { + err("usage: xapi-to workers domains retry "); + } + output( + await client.retryWorkerDomain( + options(), + domainArgs[0], + domainArgs[1], + ), + ); + return; + } + err("usage: xapi-to workers domains ..."); + } + case "schedules": { + const [action, ...scheduleArgs] = rest; + if (action === "list") { + assertFlags(flags); + output( + await client.listWorkerSchedules( + options(), + oneId( + scheduleArgs, + "usage: xapi-to workers schedules list ", + ), + ), + ); + return; + } + if (action === "create") { + assertFlags(flags, [ + "name", + "cron", + "env", + "path", + "timezone", + "method", + "body", + "timeout-ms", + "max-retries", + ]); + const id = oneId( + scheduleArgs, + "usage: xapi-to workers schedules create [flags]", + ); + let body: Record | undefined; + if (flags.body) { + try { + body = JSON.parse(flags.body); + } catch { + err("--body must be valid JSON"); + } + } + output( + await client.createWorkerSchedule(options(), id, { + name: required(flags.name, "--name"), + cron: required(flags.cron, "--cron"), + environment: environment(flags.env), + path: required(flags.path, "--path"), + timezone: flags.timezone || "UTC", + method: (flags.method || "POST").toUpperCase(), + body, + timeoutMs: flags["timeout-ms"] + ? Number(flags["timeout-ms"]) + : 30000, + maxRetries: flags["max-retries"] ? Number(flags["max-retries"]) : 2, + }), + ); + return; + } + if (action === "runs") { + assertFlags(flags); + if (scheduleArgs.length !== 2) + err( + "usage: xapi-to workers schedules runs ", + ); + output( + await client.workerScheduleRuns( + options(), + scheduleArgs[0], + scheduleArgs[1], + ), + ); + return; + } + if (action === "run") { + assertFlags(flags); + if (scheduleArgs.length !== 2) + err("usage: xapi-to workers schedules run "); + output( + await client.runWorkerScheduleNow( + options(), + scheduleArgs[0], + scheduleArgs[1], + ), + ); + return; + } + if (action === "pause" || action === "resume") { + assertFlags(flags); + if (scheduleArgs.length !== 2) + err( + `usage: xapi-to workers schedules ${action} `, + ); + output( + await client.updateWorkerSchedule( + options(), + scheduleArgs[0], + scheduleArgs[1], + { enabled: action === "resume" }, + ), + ); + return; + } + if (action === "delete") { + assertFlags(flags, ["yes"]); + if (scheduleArgs.length !== 2) + err( + "usage: xapi-to workers schedules delete --yes", + ); + if (flags.yes !== "true") + err("refusing to delete a schedule without --yes"); + output( + await client.deleteWorkerSchedule( + options(), + scheduleArgs[0], + scheduleArgs[1], + ), + ); + return; + } + err( + "usage: xapi-to workers schedules list|create|runs|pause|resume|delete ...", + ); + } + case "bindings": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers bindings"); + output(await client.workerBindingCatalog(options())); + return; + case "resources": { + const [action, ...resourceArgs] = rest; + if (action === "list") { + assertFlags(flags, ["env"]); + output( + await client.listWorkerResources( + options(), + oneId( + resourceArgs, + "usage: xapi-to workers resources list --env ENV", + ), + environment(flags.env), + ), + ); + return; + } + if (action === "create") { + assertFlags(flags, ["env", "type", "binding", "class-name", "retention-price-version"]); + const id = oneId( + resourceArgs, + "usage: xapi-to workers resources create --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME", + ); + const type = required(flags.type, "--type"); + const typeMap: Record = { + kv: "kv_namespace", + d1: "d1_database", + r2: "r2_bucket", + do: "durable_object", + queue: "queue", + workflow: "workflow", + }; + if (!typeMap[type]) + err("--type must be kv, d1, r2, do, queue, or workflow"); + const className = + type === "do" + ? required(flags["class-name"], "--class-name") + : undefined; + output( + await client.createWorkerResource( + options(), + id, + environment(flags.env), + { + type: typeMap[type], + retentionPriceVersion: flags["retention-price-version"], + bindingName: required(flags.binding, "--binding"), + ...(className ? { className } : {}), + }, + ), + ); + return; + } + if (action === "delete") { + assertFlags(flags, ["env", "yes"]); + if (resourceArgs.length !== 2) { + err( + "usage: xapi-to workers resources delete --env ENV --yes", + ); + } + if (flags.yes !== "true") { + err("refusing to delete a managed resource without --yes"); + } + output( + await client.deleteWorkerResource( + options(), + resourceArgs[0], + environment(flags.env), + resourceArgs[1], + ), + ); + return; + } + err("usage: xapi-to workers resources ..."); + } + case "secrets": { + const [action, ...secretArgs] = rest; + if (action === "list") { + assertFlags(flags, ["env"]); + output( + await client.listWorkerSecrets( + options(), + oneId( + secretArgs, + "usage: xapi-to workers secrets list --env ENV", + ), + environment(flags.env), + ), + ); + return; + } + if (action === "set") { + assertFlags(flags, ["env", "from-env", "value"]); + if (secretArgs.length !== 2) { + err( + "usage: xapi-to workers secrets set --env ENV --from-env VARIABLE", + ); + } + if (flags["from-env"] && flags.value) { + err("use either --from-env or --value, not both"); + } + const value = flags["from-env"] + ? process.env[flags["from-env"]] + : flags.value; + if (value === undefined || value === "true" || value === "") { + err( + flags["from-env"] + ? `environment variable ${flags["from-env"]} is empty or missing` + : "provide --from-env VARIABLE (recommended) or --value VALUE", + ); + } + output( + await client.putWorkerSecret( + options(), + secretArgs[0], + environment(flags.env), + secretArgs[1], + value, + ), + ); + return; + } + if (action === "delete") { + assertFlags(flags, ["env", "yes"]); + if (secretArgs.length !== 2) { + err( + "usage: xapi-to workers secrets delete --env ENV --yes", + ); + } + if (flags.yes !== "true") { + err("refusing to delete a secret without --yes"); + } + output( + await client.deleteWorkerSecret( + options(), + secretArgs[0], + environment(flags.env), + secretArgs[1], + ), + ); + return; + } + err("usage: xapi-to workers secrets ..."); + } + case "provider-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers provider-status"); + output(await client.workerProviderStatus(options())); + return; + case "capabilities": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers capabilities"); + output(await client.workerProviderCapabilities(options())); + return; + case "artifact-provider-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers artifact-provider-status"); + output(await client.workerArtifactProviderStatus(options())); + return; + case "build-provider-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers build-provider-status"); + output(await client.workerBuildProviderStatus(options())); + return; + case "delete": + assertFlags(flags, ["yes"]); + if (flags.yes !== "true") + err("refusing to delete without --yes", { + retention: "soft-deleted for 30 days", + }); + output( + await client.deleteWorker( + options(), + oneId(rest, "usage: xapi-to workers delete --yes"), + ), + ); + return; + default: + err(`unknown workers command: ${command}`, { + hint: "run xapi-to workers --help", + }); + } +} diff --git a/src/index.ts b/src/index.ts index a4ffe61..bed337c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ * xapi-to get-batch [id ...] * xapi-to call --input '{"k":"v"}' [--code curl|py|js|ts|go] * xapi-to sandbox run --command + * xapi-to workers list * * xapi-to config show * xapi-to config set apiKey= @@ -41,6 +42,7 @@ import * as taskCmds from './commands/task.ts'; import * as sandboxCmds from './commands/sandbox.ts'; import * as providerCmds from './commands/provider.ts'; import * as skillCmds from './commands/skill.ts'; +import * as workersCmds from './commands/workers.ts'; const { OAUTH_HELP } = oauthCmds; import { parseArgs } from './args.ts'; @@ -100,6 +102,10 @@ COMMANDS spec|submit|status|wait Run "xapi-to skill --help" for local directory and GitHub workflows + workers Deploy and manage hosted Cloudflare Workers + list|get|create|deploy|budget|audit|bindings|provider-status|delete + Run "xapi-to workers --help" for budgets, environments, and deploy flags + oauth bind [--provider twitter] Bind Twitter OAuth to your API key oauth status List current OAuth bindings oauth unbind Remove an OAuth binding @@ -155,6 +161,7 @@ EXAMPLES xapi-to task poll 550e8400-e29b-41d4-a716-446655440000 xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m xapi-to sandbox run --command 'python3 -c "print(6*7)"' + xapi-to workers list --format table xapi-to categories xapi-to services --format table xapi-to config set apiKey=xapi_abc123 @@ -252,6 +259,9 @@ async function main() { break; } + case 'workers': + return workersCmds.workersCommand(rest, flags); + // ── OAuth commands ── case 'oauth': { if (flags.help || rest.length === 0) { diff --git a/src/tests/skill-workers-guide.test.ts b/src/tests/skill-workers-guide.test.ts new file mode 100644 index 0000000..3b33422 --- /dev/null +++ b/src/tests/skill-workers-guide.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const skill = readFileSync( + new URL('../../skills/xapi/SKILL.md', import.meta.url), + 'utf8', +); +const guide = readFileSync( + new URL('../../skills/xapi/guides/workers.md', import.meta.url), + 'utf8', +); + +describe('bundled xAPI Workers skill guide', () => { + it('routes hosted Worker tasks to the progressively loaded guide', () => { + expect(skill).toContain('Read `guides/workers.md`'); + expect(skill).toContain('`workers init`'); + expect(skill).toContain('`workers promote --to production`'); + }); + + it('prefers project deployment and covers import, CI, recovery, and rollback boundaries', () => { + expect(guide).toContain('xapi workers templates'); + expect(guide).toContain('xapi workers init my-agent --template persistent-agent'); + expect(guide).toContain('init --from-wrangler ./wrangler.jsonc'); + expect(guide).toContain('xapi workers plan --env preview'); + expect(guide).toContain('xapi workers push --env preview'); + expect(guide).toContain('xapi workers promote --to production'); + expect(guide).toContain('xapi workers rollback --env production'); + expect(guide).toContain('The project workflow does not require Git'); + expect(guide).toContain('--non-interactive'); + expect(guide).toContain('never deletes an extra stateful resource or Secret'); + expect(guide).toContain('does **not** restore or migrate KV'); + expect(guide).toContain('XAPI_API_HOST=test.xapi.to'); + }); + + it('uses Artifact upload as the default and Sandbox only as an option', () => { + expect(guide).toContain('workers upload '); + expect(guide).toContain('workers deploy '); + expect(guide).toContain('--artifact '); + expect(guide).toContain('### Optional Sandbox build'); + expect(guide).not.toContain('--build '); + }); + + it('preserves key isolation, idempotency, budgets, and terminal status rules', () => { + expect(guide).toContain('never embedded in a bundle'); + expect(guide).toContain('between $0.10 and $100 per day'); + expect(guide).toContain('Reuse an upload key only for identical bundle bytes'); + expect(guide).toContain('`status: ACTIVE`'); + }); + + it('covers persistent agents, live capability diagnostics, billing, logs, and domains', () => { + expect(guide).toContain('workers capabilities'); + expect(guide).toContain('--type do'); + expect(guide).toContain('--type queue'); + expect(guide).toContain('--type workflow'); + expect(guide).toContain('workers schedules create'); + expect(guide).toContain('Tail Worker console messages'); + expect(guide).toContain('fails closed'); + expect(guide).toContain('workers domains retry'); + expect(guide).toContain('D1 Edit'); + }); +}); diff --git a/src/tests/workers-billing-output.test.ts b/src/tests/workers-billing-output.test.ts new file mode 100644 index 0000000..2c2b100 --- /dev/null +++ b/src/tests/workers-billing-output.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "bun:test"; +import { + formatWorkerBillingOutput, + formatWorkerBillingResponse, + workerBillingOutputMode, +} from "../workers-billing-output.ts"; + +const envelope = { + schemaVersion: 1, + snapshotId: "snapshot-1", + snapshotTime: "2026-09-02T12:00:00.000Z", + completeThrough: "2026-09-02T11:55:00.000Z", + dataQuality: "COMPLETE", + workerId: "worker-1", + environment: "production", +} as const; + +describe("Worker billing output selection", () => { + it("defaults to human output independently of TTY state", () => { + expect(workerBillingOutputMode({})).toBe("human"); + expect(workerBillingOutputMode({ format: "table" })).toBe("human"); + expect(workerBillingOutputMode({ format: "pretty" })).toBe("human"); + }); + + it("supports both explicit JSON forms and rejects conflicts deterministically", () => { + expect(workerBillingOutputMode({ json: "true" })).toBe("json"); + expect(workerBillingOutputMode({ format: "json" })).toBe("json"); + expect(() => + workerBillingOutputMode({ json: "true", format: "table" }), + ).toThrow("--json cannot be combined with --format"); + expect(() => workerBillingOutputMode({ json: "false" })).toThrow( + "--json does not accept a value", + ); + }); + + it("emits the API object unchanged in JSON mode", () => { + const fixture = { + ...envelope, + completeThrough: null, + dataQuality: "PARTIAL", + data: { + currency: "USD", + entries: [{ amountUsd: "-0.000000300000000001" }], + hasMore: true, + nextCursor: "opaque+/= cursor.do-not-decode", + }, + }; + + expect( + JSON.parse(formatWorkerBillingOutput("ledger", fixture, "json")), + ).toEqual(fixture); + }); +}); + +describe("Worker billing human output", () => { + it("matches the overview golden without converting or recomputing money", () => { + const rendered = formatWorkerBillingResponse("overview", { + ...envelope, + data: { + lifecycleState: "LOW_BALANCE", + dailyBudgetUsd: "10000000000000000000.00000001", + budgetRemainingUsd: "9999999999999999998.750000309999999999", + settledUsd: "1.25000000", + reservedUsd: null, + estimatedUsd: "-0.000000300000000001", + exposureUsd: "1.249999700000000001", + safetyReserveUsd: null, + ledgerNetUsd: "1.25000000", + ledgerEntries: 2, + providerOutage: false, + reasonCodes: ["LOW_BALANCE"], + resourceTotals: [ + { + kind: "worker_runtime", + resourceCount: 1, + metrics: ["WORKER_REQUEST"], + settledLedgerEntries: 2, + settledUsd: "1.25000000", + reservedUsd: null, + estimatedUsd: "0", + exposureUsd: null, + dataQuality: "INDETERMINATE", + reasonCodes: ["ESTIMATED_TOTAL_UNKNOWN"], + }, + ], + }, + }); + + expect(rendered).toBe(`xAPI Worker Billing · Overview +──────────────────────────────────────────────────────────────────────────────────────── + Worker worker-1 + Environment production + Snapshot snapshot-1 + Snapshot time 2026-09-02T12:00:00.000Z + Data quality COMPLETE + Complete through 2026-09-02T11:55:00.000Z + + Lifecycle LOW_BALANCE + Daily budget $10000000000000000000.00000001 + Budget remaining $9999999999999999998.750000309999999999 + Settled $1.25000000 + Reserved — + Estimated $-0.000000300000000001 + Exposure $1.249999700000000001 + Safety reserve — + Ledger net $1.25000000 + Ledger entries 2 + Provider outage false + Reasons LOW_BALANCE + +Resource totals + Resource Count Settled Reserved Estimated Exposure Quality Reasons + ────────────── ───── ─────────── ──────── ───────── ──────── ───────────── ─────────────────────── + worker_runtime 1 $1.25000000 — $0 — INDETERMINATE ESTIMATED_TOTAL_UNKNOWN +────────────────────────────────────────────────────────────────────────────────────────`); + expect(rendered).not.toContain("1e+"); + }); + + it("keeps unattributed managed-resource estimates unknown", () => { + const rendered = formatWorkerBillingResponse("overview", { + ...envelope, + data: { + budgetRemainingUsd: null, + resourceTotals: [ + { + kind: "kv", + resourceCount: 1, + settledUsd: "0.0002", + reservedUsd: "0", + estimatedUsd: null, + exposureUsd: null, + dataQuality: "INDETERMINATE", + reasonCodes: ["ESTIMATED_ATTRIBUTION_NOT_PERSISTED"], + }, + ], + }, + }); + + expect(rendered).toContain("Budget remaining —"); + expect(rendered).toContain("$0.0002"); + expect(rendered).toContain("ESTIMATED_ATTRIBUTION_NOT_PERSISTED"); + expect(rendered).not.toContain("$null"); + }); + + it("renders explicit gaps, nulls, and incomplete freshness prominently", () => { + const rendered = formatWorkerBillingResponse("usage", { + ...envelope, + completeThrough: null, + dataQuality: "PARTIAL", + data: { + from: "2026-09-02T11:00:00.000Z", + to: "2026-09-02T12:00:00.000Z", + bucketCount: 12, + gapCount: 1, + partialCount: 1, + metric: null, + resourceId: null, + buckets: [ + { + start: "2026-09-02T11:00:00.000Z", + status: "COMPLETE", + facts: [{ metric: "WORKER_REQUEST", quantity: "7" }], + }, + { + start: "2026-09-02T11:05:00.000Z", + status: "GAP", + facts: [], + }, + ], + }, + }); + + expect(rendered).toContain("Data quality PARTIAL"); + expect(rendered).toContain("Complete through —"); + expect(rendered).toContain( + "! PARTIAL: provider data may be delayed or incomplete", + ); + expect(rendered).toContain("! GAP"); + expect(rendered).toContain("Metric —"); + expect(rendered).not.toContain("$0"); + }); + + it("renders all eight persisted lifecycle states without assumptions", () => { + for (const state of [ + "ACTIVE", + "LOW_BALANCE", + "SUSPENDING", + "SUSPENDED_GRACE", + "RESUMING", + "PENDING_DELETION", + "DELETING", + "DELETED", + ]) { + const rendered = formatWorkerBillingResponse("lifecycle", { + ...envelope, + data: { + state, + reasonCode: null, + phase: null, + completedSteps: 0, + totalSteps: 0, + nextStep: null, + blockerCodes: [], + suspendedAt: null, + graceDeadlineAt: null, + deletionEarliestAt: null, + deletedAt: null, + retentionCostUsd: null, + finalMeteringStatus: null, + r2DispositionStatus: null, + legalHold: false, + paymentInFlight: false, + approvalStatus: "NOT_REQUIRED", + availableActions: [], + }, + }); + expect(rendered).toContain(`State ${state}`); + expect(rendered).toContain("Reason —"); + expect(rendered).toContain("Retention cost —"); + } + }); + + it.each([ + ["prices", { version: null, effectiveFrom: null, rates: [] }], + ["ledger", { entries: [], hasMore: false, nextCursor: null }], + [ + "forecast", + { + spendableUsd: null, + burnRate1hUsd: null, + burnRate24hUsd: null, + burnRateUsdPerHour: null, + timeToZeroHours: null, + safetyReserveUsd: null, + providerDelayReserveUsd: null, + retentionStorageReserveUsd: null, + asyncShutdownReserveUsd: null, + providerOutage: null, + }, + ], + [ + "risk", + { accountRisk: null, environmentExposure: null, apiKeyExposure: null }, + ], + ] as const)("renders the %s family safely", (kind, data) => { + const rendered = formatWorkerBillingResponse(kind, { ...envelope, data }); + expect(rendered).toContain(`xAPI Worker Billing`); + expect(rendered).toContain("Data quality COMPLETE"); + expect(rendered).not.toContain("undefined"); + }); +}); diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts new file mode 100644 index 0000000..91975c2 --- /dev/null +++ b/src/tests/workers-client.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { + createWorker, + createWorkerBuild, + createWorkerResource, + createWorkerSchedule, + deployWorker, + putWorkerSecret, + listWorkers, + runWorkerScheduleNow, + listWorkerDomains, + retryWorkerDomain, + rollbackWorker, + workerBillingStatus, + workerBillingQuery, + workerInvocationLogs, + workerProviderCapabilities, + workerRuntimeLogs, + workerUsage, + workerMeteredUsage, + uploadWorkerArtifact, +} from "../workers-client.ts"; + +const options = { apiHost: "test.xapi.to", apiKey: "sk-test-value" }; +let fetchSpy: ReturnType | undefined; + +afterEach(() => fetchSpy?.mockRestore()); + +describe("workers client", () => { + it("reads scoped source windows with the original xAPI authentication", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ storageCollection: { items: [] } }), { status: 200, headers: { "content-type": "application/json" } })) as any; + await workerMeteredUsage(options, "worker/1", "preview"); + const [url, init] = fetchSpy.mock.calls[0] as any[]; + expect(url).toBe("https://test.xapi.to/api/v1/workers/worker%2F1/environments/preview/metered-usage"); + expect(init.headers["XAPI-KEY"]).toBe("sk-test-value"); + expect(init.redirect).toBe("manual"); + }); + it("lists Workers through the versioned test API with the scoped key header", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify([{ id: "worker-1" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + + await expect(listWorkers(options)).resolves.toEqual([{ id: "worker-1" }]); + const [url, init] = fetchSpy.mock.calls[0] as any[]; + expect(url).toBe("https://test.xapi.to/api/v1/workers"); + expect(init.headers["XAPI-KEY"]).toBe("sk-test-value"); + expect(init.redirect).toBe("manual"); + }); + + it("sends create input as JSON without automatic write retries", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "worker-2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + const body = { + name: "Agent", + slug: "agent", + previewDailyBudgetUsd: 0.25, + productionDailyBudgetUsd: 2, + }; + await createWorker(options, body); + const [, init] = fetchSpy.mock.calls[0] as any[]; + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual(body); + }); + + it("targets the requested Worker deployment endpoint", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "ACTIVE" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await deployWorker(options, "worker/id", { + environment: "preview", + artifactId: "artifact-1", + idempotencyKey: "showcase-v1", + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/deployments", + ); + }); + + it("targets the environment-scoped rollback endpoint with a stable retry key", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "ACTIVE" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await rollbackWorker(options, "worker/id", "production", { + deploymentId: "deployment-1", + idempotencyKey: "rollback-key-1", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/rollback", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + deploymentId: "deployment-1", + idempotencyKey: "rollback-key-1", + }); + }); + + it("uploads a bundled module as an immutable artifact", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "artifact-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await uploadWorkerArtifact(options, "worker/id", { + moduleCode: "export default {}", + idempotencyKey: "showcase-upload-v1", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/artifacts", + ); + expect(JSON.parse(init.body).idempotencyKey).toBe("showcase-upload-v1"); + }); + + it("uses the server-side build endpoint with an extended timeout", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "build-1", status: "SUCCEEDED" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await createWorkerBuild(options, "worker/id", { + files: [ + { + path: "src/index.ts", + content: "export default {}", + encoding: "utf8", + }, + ], + entrypoint: "src/index.ts", + buildCommand: "npm run build", + outputPath: "dist/index.mjs", + idempotencyKey: "showcase-build-v1", + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/builds", + ); + }); + + it("creates an environment-isolated managed resource", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "resource-1", status: "ACTIVE" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await createWorkerResource(options, "worker/id", "production", { + type: "kv_namespace", + bindingName: "STATE", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/resources", + ); + expect(JSON.parse(init.body)).toEqual({ + type: "kv_namespace", + bindingName: "STATE", + }); + }); + + it("sends a secret only in the JSON request body", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ bindingName: "MODEL_KEY", version: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await putWorkerSecret( + options, + "worker/id", + "preview", + "MODEL_KEY", + "private-value", + ); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/preview/secrets/MODEL_KEY", + ); + expect(target).not.toContain("private-value"); + expect(JSON.parse(init.body)).toEqual({ value: "private-value" }); + }); + + it("reads environment-isolated invocation metadata", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ items: [], contentCaptured: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await workerInvocationLogs(options, "worker/id", "preview"); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/preview/invocations", + ); + }); + + it("reads runtime telemetry, usage, domains, and provider capabilities", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as any, + ) as any; + + await workerRuntimeLogs(options, "worker/id", "production"); + await workerUsage(options, "worker/id", "production"); + await workerUsage(options, "worker/id"); + await workerBillingStatus(options); + await listWorkerDomains(options, "worker/id"); + await retryWorkerDomain(options, "worker/id", "domain/id"); + await workerProviderCapabilities(options); + + expect(fetchSpy.mock.calls.map((call: any[]) => call[0])).toEqual([ + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/runtime-logs", + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/usage", + "https://test.xapi.to/api/v1/workers/worker%2Fid/usage", + "https://test.xapi.to/api/v1/workers/billing/status", + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains", + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains/domain%2Fid/retry", + "https://test.xapi.to/api/v1/workers/provider/capabilities", + ]); + expect((fetchSpy.mock.calls[5][1] as RequestInit).method).toBe("POST"); + }); + + it("targets every environment billing family and preserves an opaque ledger cursor", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ schemaVersion: 1, data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as any, + ) as any; + + for (const kind of [ + "prices", + "overview", + "usage", + "ledger", + "forecast", + "risk", + "lifecycle", + ] as const) { + await workerBillingQuery(options, "worker/id", "production", kind); + } + await workerBillingQuery(options, "worker/id", "preview", "ledger", { + snapshotTime: "2026-09-02T12:00:00.000Z", + cursor: "opaque+/= cursor.do-not-decode", + limit: "25", + metric: "WORKER_REQUEST", + resourceId: "resource/id", + }); + + expect( + fetchSpy.mock.calls.slice(0, 7).map((call: any[]) => call[0]), + ).toEqual( + [ + "prices", + "overview", + "usage", + "ledger", + "forecast", + "risk", + "lifecycle", + ].map( + (kind) => + `https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/billing/${kind}`, + ), + ); + const ledgerUrl = new URL(fetchSpy.mock.calls[7][0] as string); + expect(ledgerUrl.pathname).toBe( + "/api/v1/workers/worker%2Fid/environments/preview/billing/ledger", + ); + expect(Object.fromEntries(ledgerUrl.searchParams)).toEqual({ + snapshotTime: "2026-09-02T12:00:00.000Z", + cursor: "opaque+/= cursor.do-not-decode", + limit: "25", + metric: "WORKER_REQUEST", + resourceId: "resource/id", + }); + }); + + it("creates and immediately runs a persistent Worker schedule", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ id: "run-1", status: "SUCCEEDED" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch, + ) as any; + await createWorkerSchedule(options, "worker/id", { + name: "heartbeat", + environment: "PREVIEW", + cron: "*/15 * * * *", + timezone: "UTC", + method: "POST", + path: "/cron", + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/schedules", + ); + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe("POST"); + + await runWorkerScheduleNow(options, "worker/id", "schedule/id"); + expect(fetchSpy.mock.calls[1][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/schedules/schedule%2Fid/run", + ); + expect((fetchSpy.mock.calls[1][1] as RequestInit).method).toBe("POST"); + }); +}); diff --git a/src/tests/workers-deployment-state.test.ts b/src/tests/workers-deployment-state.test.ts new file mode 100644 index 0000000..d08f18b --- /dev/null +++ b/src/tests/workers-deployment-state.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { ensureActiveDeployment } from "../workers-push.ts"; +import { deploymentPrefix } from "../workers-deployment-state.ts"; +import { RequestTimeoutError } from "../client.ts"; + +function platform() { + const environment = { id: "env", name: "PREVIEW", activeDeploymentId: null as string | null, bindings: [] }; + const resources: Record[] = []; + const secrets: Record[] = []; + const deployments: Record[] = []; + let failOnce = false; + const api = { + getWorker: async () => ({ id: "worker", environments: [environment], deployments }), + listWorkerResources: async () => resources, + listWorkerSecrets: async () => secrets, + deployWorker: async (_options: unknown, _id: string, input: Record) => { + const d = { ...input, id: `d${deployments.length}`, environmentId: "env", status: "ACTIVE" }; + deployments.push(d); + environment.activeDeploymentId = d.id; + if (failOnce) { failOnce = false; throw new RequestTimeoutError(1000); } + return d; + }, + }; + const run = (date = "2026-09-07", artifact = "same-artifact") => ensureActiveDeployment(api, { apiHost: "localhost:3148", apiKey: "test" }, "worker", artifact, + "preview", { compatibilityDate: date, compatibilityFlags: [] }, async () => {}); + return { run, resources, secrets, deployments, environment, uncertain: () => { failOnce = true; } }; +} + +test("same code: add, replace and explicitly remove bindings deploy; unchanged repeats do not", async () => { + const p = platform(); + const first = await p.run(); + expect((await p.run()).deployment.id).toBe(first.deployment.id); + p.resources.push({ id: "resource", bindingName: "STATE", type: "KV_NAMESPACE", status: "ACTIVE", providerResourceId: "kv1" }); + const added = await p.run(); + expect(added.deployment.id).not.toBe(first.deployment.id); + expect((await p.run()).deployment.id).toBe(added.deployment.id); + p.resources[0].providerResourceId = "kv2"; + const replaced = await p.run(); + expect(replaced.deployment.id).not.toBe(added.deployment.id); + p.resources.length = 0; + const removed = await p.run(); + expect(removed.deployment.id).not.toBe(first.deployment.id); + expect((await p.run()).deployment.id).toBe(removed.deployment.id); + expect(p.deployments).toHaveLength(4); +}); + +test("compatibility and secret versions change the deployment, not the Artifact", async () => { + const p = platform(); + await p.run(); + await p.run("2026-09-06"); + p.secrets.push({ bindingName: "TOKEN", version: 1 }); + await p.run("2026-09-06"); + p.secrets[0].version = 2; + await p.run("2026-09-06"); + await p.run("2026-09-06"); + expect(p.deployments).toHaveLength(4); + expect(new Set(p.deployments.map(d => d.artifactId)).size).toBe(1); +}); + +test("historical ACTIVE artifact is not mistaken for the current activation; lost response reconciles", async () => { + const p = platform(); + await p.run(); + await p.run("2026-09-07", "other-artifact"); + p.uncertain(); + const restored = await p.run(); + expect(restored.deployment.id).toBe("d2"); + expect((await p.run()).deployment.id).toBe("d2"); + expect(p.deployments).toHaveLength(3); + expect(restored.idempotencyKey.length).toBeLessThanOrEqual(128); +}); + +test("fingerprint ignores polling noise and resource order, but includes environment bindings", () => { + const fingerprint = (resources: Record[], bindings: unknown[] = []) => + deploymentPrefix("worker", "preview", "artifact", {}, { bindings }, resources, []); + const a = { id: "1", bindingName: "A", type: "D1_DATABASE", status: "ACTIVE", config: { file_size: 10 } }; + const b = { id: "2", bindingName: "B", type: "KV_NAMESPACE", status: "ACTIVE" }; + expect(fingerprint([a, b])).toBe(fingerprint([b, { ...a, updatedAt: "later", config: { file_size: 20 } }])); + expect(fingerprint([a])).not.toBe(fingerprint([a], [{ type: "plain_text", name: "MODE", text: "new" }])); +}); diff --git a/src/tests/workers-init.test.ts b/src/tests/workers-init.test.ts new file mode 100644 index 0000000..8ae2dc2 --- /dev/null +++ b/src/tests/workers-init.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import ts from "typescript"; +import { initWorkerProject } from "../workers-init.ts"; +import { loadWorkerProject } from "../workers-project.ts"; +import { listWorkerTemplates, loadWorkerTemplate } from "../workers-templates.ts"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); + +function workspace() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "xapi-worker-init-"))); + roots.push(root); + return root; +} + +describe("workers init", () => { + for (const template of ["chat", "agent"] as const) { + test(`${template} rejects JSON null and non-string messages without invoking AI`, async () => { + const result = initWorkerProject({ cwd: workspace(), target: `invalid-${template}`, template }); + const build = await Bun.build({ entrypoints: [join(result.rootDir, "src/index.ts")], outdir: join(result.rootDir, "dist"), format: "esm", target: "browser" }); + const module = await import(build.outputs[0].path); + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = Object.assign(async () => { calls++; throw new Error("Unexpected AI request"); }, { preconnect: originalFetch.preconnect }); + try { + for (const input of [null, [], { message: 42 }, { message: " " }]) { + const response = await module.default.fetch(new Request("https://example.test/chat", { method: "POST", body: JSON.stringify(input) }), { MODEL_KEY: "test" }); + expect(response.status).toBe(400); + } + expect(calls).toBe(0); + } finally { globalThis.fetch = originalFetch; } + }); + } + for (const template of listWorkerTemplates().map((item) => item.id)) { + test(`creates a buildable ${template} project without Git or network access`, async () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: `demo-${template}`, + template, + compatibilityDate: "2026-08-26", + }); + expect(existsSync(join(result.rootDir, ".git"))).toBe(false); + const project = loadWorkerProject(result.rootDir); + expect(project.config.worker.slug).toBe(`demo-${template}`); + expect(project.config.worker.template).toBe( + loadWorkerTemplate(template).productTemplate, + ); + const sourcePath = join(result.rootDir, "src/index.ts"); + const source = readFileSync(sourcePath, "utf8"); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + reportDiagnostics: true, + }); + expect(transpiled.diagnostics || []).toHaveLength(0); + expect(transpiled.outputText).toContain("export default"); + const program = ts.createProgram([sourcePath], { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ["lib.es2022.d.ts", "lib.webworker.d.ts"], + strict: true, + noEmit: true, + skipLibCheck: true, + }); + expect(ts.getPreEmitDiagnostics(program)).toHaveLength(0); + const build = await Bun.build({ + entrypoints: [sourcePath], + outdir: join(result.rootDir, "dist"), + format: "esm", + target: "browser", + }); + expect(build.success).toBe(true); + expect(build.outputs).toHaveLength(1); + expect(readFileSync(build.outputs[0].path, "utf8")).toContain( + "as default", + ); + }); + } + + test("persistent-agent declares managed resources, secrets, and support files", () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: "persistent-demo", + template: "persistent-agent", + compatibilityDate: "2026-08-26", + }); + const project = loadWorkerProject(result.rootDir); + expect(project.config.environments.preview.resources.map((item) => item.type)).toEqual([ + "kv_namespace", + "d1_database", + "r2_bucket", + "durable_object", + "queue", + "workflow", + ]); + expect(project.config.environments.preview.secrets).toEqual([ + "APP_TOKEN", + "MODEL_KEY", + ]); + expect(existsSync(join(result.rootDir, "migrations/0001_init.sql"))).toBe(true); + expect(existsSync(join(result.rootDir, "scripts/smoke.mjs"))).toBe(true); + const packageJson = JSON.parse(readFileSync(join(result.rootDir, "package.json"), "utf8")); + expect(packageJson.scripts["test:remote"]).toBe("node scripts/smoke.mjs"); + }); + + test("chat requests a streaming OpenAI-compatible completion", () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: "streaming-chat", + template: "chat", + compatibilityDate: "2026-08-26", + }); + const source = readFileSync(join(result.rootDir, "src/index.ts"), "utf8"); + expect(source).toContain('stream: true'); + expect(source).toContain('new Response(upstream.body'); + }); + + test("persistent-agent public home renders and ships parseable workbench JavaScript", async () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: "persistent-ui", + template: "persistent-agent", + compatibilityDate: "2026-08-26", + }); + const build = await Bun.build({ + entrypoints: [join(result.rootDir, "src/index.ts")], + outdir: join(result.rootDir, "dist"), + format: "esm", + target: "browser", + }); + expect(build.success).toBe(true); + const worker = await import(`${build.outputs[0].path}?test=${Date.now()}`); + const response = await worker.default.fetch( + new Request("https://persistent-ui-preview.example.test/"), + {}, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + const html = await response.text(); + expect(html).toContain("My Agent is running."); + expect(html).toContain("application access token, not your XAPI_KEY"); + const script = html.match(/ + +`, + { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }, + }, + ); +} + +function sameSecret(actual: string, expected: string): boolean { + if (actual.length !== expected.length) return false; + let different = 0; + for (let index = 0; index < actual.length; index += 1) { + different |= actual.charCodeAt(index) ^ expected.charCodeAt(index); + } + return different === 0; +} + +function authorized(request: Request, token?: string): boolean { + if (!token) return false; + return sameSecret( + request.headers.get("authorization") || "", + `Bearer ${token}`, + ); +} + +async function hmac(token: string, payload: unknown): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(token), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signed = new Uint8Array( + await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(JSON.stringify(payload)), + ), + ); + let binary = ""; + for (const byte of signed) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function verifiedEnvelope( + token: string | undefined, + envelope: { payload?: unknown; signature?: string } | undefined, +): Promise { + if (!token || !envelope?.signature || envelope.payload === undefined) { + return false; + } + return sameSecret(envelope.signature, await hmac(token, envelope.payload)); +} + +async function body(request: Request): Promise { + const input: unknown = await request.json().catch(() => ({})); + return (input && typeof input === "object" && !Array.isArray(input) ? input : {}) as T; +} + +async function record(env: Env, kind: string, payload: unknown): Promise { + await env.DB.prepare( + "INSERT INTO agent_events (id, kind, payload, created_at) VALUES (?, ?, ?, ?)", + ) + .bind(crypto.randomUUID(), kind, JSON.stringify(payload), new Date().toISOString()) + .run(); +} + +export class AgentState { + constructor( + private readonly state: DurableObjectState, + private readonly _env: Env, + ) {} + + async fetch(request: Request): Promise { + if (request.method === "GET") { + return json( + (await this.state.storage.get("session")) || { + messages: [], + updatedAt: new Date(0).toISOString(), + }, + ); + } + if (request.method === "PUT") { + const next = await body(request); + await this.state.storage.put("session", next); + return json(next); + } + return json({ error: "method_not_allowed" }, 405); + } +} + +function sessionStub(env: Env, session: string): DurableObjectStub { + return env.AGENT_STATE.get(env.AGENT_STATE.idFromName(session)); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/" && request.method === "GET") { + return home(); + } + if (url.pathname === "/health") { + return json({ ok: true, project: "{{PROJECT_SLUG}}", template: "persistent-agent" }); + } + const trigger = request.headers.get("x-xapi-trigger"); + const internalRoute = + url.pathname === "/queue-consume" || url.pathname === "/workflow-step"; + const internalPayload = internalRoute + ? await body<{ payload?: unknown; signature?: string }>(request.clone()) + : undefined; + const internalAuthorized = await verifiedEnvelope( + env.APP_TOKEN, + internalPayload, + ); + const cronAuthorized = url.pathname === "/cron" && trigger === "cron"; + if ( + !authorized(request, env.APP_TOKEN) && + !internalAuthorized && + !cronAuthorized + ) { + return json({ error: env.APP_TOKEN ? "unauthorized" : "APP_TOKEN_not_configured" }, env.APP_TOKEN ? 401 : 503); + } + + if (url.pathname === "/setup" && request.method === "POST") { + await env.DB.exec( + "CREATE TABLE IF NOT EXISTS agent_events (id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)", + ); + await Promise.all([ + env.CACHE.put("setup", new Date().toISOString()), + env.FILES.put("setup.json", JSON.stringify({ project: "{{PROJECT_SLUG}}", ok: true })), + ]); + await record(env, "setup", { project: "{{PROJECT_SLUG}}" }); + return json({ ok: true, resources: ["KV", "D1", "R2"] }); + } + + if (url.pathname === "/state" && request.method === "GET") { + const session = url.searchParams.get("session") || "default"; + return sessionStub(env, session).fetch(new Request("https://agent-state.local/")); + } + + if (url.pathname === "/chat" && request.method === "POST") { + if (!env.MODEL_KEY) return json({ error: "MODEL_KEY_not_configured" }, 503); + const input = await body<{ message?: string; session?: string }>(request); + if (!input.message) return json({ error: "message_required" }, 400); + const session = input.session || "default"; + const stub = sessionStub(env, session); + const previous = (await (await stub.fetch(new Request("https://agent-state.local/"))).json()) as SessionState; + const messages = [...previous.messages, { role: "user" as const, content: input.message }].slice(-20); + const aiBaseUrl = + env.XAPI_AI_BASE_URL && env.XAPI_AI_BASE_URL !== "https://ai.xapi.to/v1" + ? env.XAPI_AI_BASE_URL + : "https://ai.xapi.to/cost/v1"; + const upstream = await fetch(aiBaseUrl + "/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${env.MODEL_KEY}`, "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-pro", messages, stream: true }), + }); + if (!upstream.ok) { + const result = (await upstream.json().catch(() => ({}))) as Record; + return json({ error: "model_request_failed", upstream: result }, upstream.status); + } + const contentType = upstream.headers.get("content-type") || ""; + if (!contentType.includes("text/event-stream") || !upstream.body) { + const result = (await upstream.json().catch(() => ({}))) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const answer = result.choices?.[0]?.message?.content || ""; + const next: SessionState = { + messages: [...messages, { role: "assistant" as const, content: answer }].slice(-20), + updatedAt: new Date().toISOString(), + }; + await stub.fetch(new Request("https://agent-state.local/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(next), + })); + await record(env, "chat", { session, messageCount: next.messages.length, streamed: false }); + return json({ session, answer }); + } + const reader = upstream.body.getReader(); + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const decoder = new TextDecoder(); + let buffer = ""; + let answer = ""; + const consume = (eventText: string): void => { + const data = eventText + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .join("\n"); + if (!data || data === "[DONE]") return; + const chunk = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string } }>; + }; + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string") answer += delta; + }; + void (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() || ""; + events.forEach(consume); + await writer.write(value); + } + buffer += decoder.decode(); + if (buffer.trim()) consume(buffer); + const next: SessionState = { + messages: [...messages, { role: "assistant" as const, content: answer }].slice(-20), + updatedAt: new Date().toISOString(), + }; + await stub.fetch(new Request("https://agent-state.local/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(next), + })); + await record(env, "chat", { session, messageCount: next.messages.length, streamed: true }); + await writer.close(); + } catch (error) { + await writer.abort(error); + } + })(); + return new Response(stream.readable, { + status: 200, + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store", + "x-accel-buffering": "no", + }, + }); + } + + if (url.pathname === "/queue" && request.method === "POST") { + const task = await body>(request); + const taskId = crypto.randomUUID(); + const payload = { taskId, task }; + await env.TASK_QUEUE.send({ + path: "/queue-consume", + body: { payload, signature: await hmac(env.APP_TOKEN!, payload) }, + }); + return json({ accepted: true, taskId }, 202); + } + + if (url.pathname === "/queue-consume" && request.method === "POST") { + const envelope = await body<{ + payload?: { taskId?: string; task?: unknown }; + }>(request); + await record(env, "queue", envelope.payload || {}); + return json({ processed: true }); + } + + if (url.pathname === "/workflow" && request.method === "POST") { + const task = await body>(request); + const payload = { task }; + const run = await env.AGENT_WORKFLOW.create({ + id: crypto.randomUUID(), + params: { + path: "/workflow-step", + body: { payload, signature: await hmac(env.APP_TOKEN!, payload) }, + }, + }); + return json({ accepted: true, workflowRunId: run.id }, 202); + } + + if (url.pathname === "/workflow-step" && request.method === "POST") { + const envelope = await body<{ payload?: { task?: unknown } }>(request); + await record(env, "workflow", envelope.payload || {}); + return json({ completed: true }); + } + + if (url.pathname === "/cron" && request.method === "POST") { + if (!env.APP_TOKEN) return json({ error: "APP_TOKEN_not_configured" }, 503); + const taskId = crypto.randomUUID(); + const payload = { + taskId, + task: { kind: "cron", at: new Date().toISOString() }, + }; + await env.TASK_QUEUE.send({ + path: "/queue-consume", + body: { + payload, + signature: await hmac(env.APP_TOKEN, payload), + }, + }); + return json({ accepted: true, taskId }, 202); + } + + return json({ error: "not_found" }, 404); + }, +}; diff --git a/templates/persistent-agent/template.json b/templates/persistent-agent/template.json new file mode 100644 index 0000000..3a1efb8 --- /dev/null +++ b/templates/persistent-agent/template.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "persistent-agent", + "version": "1.0.0", + "name": "Persistent DeepSeek Agent", + "description": "Production-oriented Agent with Durable Object state, KV, D1, R2, Queue, Workflow, cron routes, authentication, and a remote smoke test.", + "productTemplate": "agent", + "defaultResources": [ + { "type": "kv_namespace", "bindingName": "CACHE" }, + { "type": "d1_database", "bindingName": "DB" }, + { "type": "r2_bucket", "bindingName": "FILES" }, + { "type": "durable_object", "bindingName": "AGENT_STATE", "className": "AgentState" }, + { "type": "queue", "bindingName": "TASK_QUEUE" }, + { "type": "workflow", "bindingName": "AGENT_WORKFLOW" } + ], + "defaultSecrets": ["APP_TOKEN", "MODEL_KEY"], + "packageScripts": { + "test:remote": "node scripts/smoke.mjs" + }, + "files": [ + { "source": "src/index.ts", "target": "src/index.ts" }, + { "source": "migrations/0001_init.sql", "target": "migrations/0001_init.sql" }, + { "source": "scripts/smoke.mjs", "target": "scripts/smoke.mjs" }, + { "source": "TEMPLATE.md", "target": "TEMPLATE.md" } + ] +} diff --git a/templates/webhook/files/src/index.ts b/templates/webhook/files/src/index.ts new file mode 100644 index 0000000..7279444 --- /dev/null +++ b/templates/webhook/files/src/index.ts @@ -0,0 +1,16 @@ +interface Env {} + +export default { + async fetch(request: Request, _env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/health") return Response.json({ ok: true }); + if (url.pathname !== "/webhook" || request.method !== "POST") { + return Response.json({ error: "not_found" }, { status: 404 }); + } + const event = await request.json().catch(() => null); + return Response.json( + { accepted: true, eventId: crypto.randomUUID(), event }, + { status: 202 }, + ); + }, +}; diff --git a/templates/webhook/template.json b/templates/webhook/template.json new file mode 100644 index 0000000..34a6d41 --- /dev/null +++ b/templates/webhook/template.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "webhook", + "version": "1.0.0", + "name": "Webhook Receiver", + "description": "Small webhook receiver that validates the route and acknowledges JSON events.", + "productTemplate": "worker", + "defaultResources": [], + "defaultSecrets": [], + "packageScripts": {}, + "files": [ + { "source": "src/index.ts", "target": "src/index.ts" } + ] +} diff --git a/templates/worker/files/src/index.ts b/templates/worker/files/src/index.ts new file mode 100644 index 0000000..3de1b9a --- /dev/null +++ b/templates/worker/files/src/index.ts @@ -0,0 +1,16 @@ +interface Env { + XAPI_AI_BASE_URL: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/health") { + return Response.json({ ok: true, runtime: "xAPI Workers", project: "{{PROJECT_SLUG}}" }); + } + return Response.json({ + message: "Hello from {{PROJECT_NAME}}", + aiBaseUrl: env.XAPI_AI_BASE_URL, + }); + }, +}; diff --git a/templates/worker/template.json b/templates/worker/template.json new file mode 100644 index 0000000..5aad84d --- /dev/null +++ b/templates/worker/template.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "worker", + "version": "1.0.0", + "name": "HTTP Worker", + "description": "Minimal HTTP Worker with a health endpoint and xAPI AI base URL binding.", + "productTemplate": "worker", + "defaultResources": [], + "defaultSecrets": [], + "packageScripts": {}, + "files": [ + { "source": "src/index.ts", "target": "src/index.ts" } + ] +} From 8b38ae8dca5d2880c10530921d0b3c98eb924ebe Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 14 Sep 2026 18:15:41 +0800 Subject: [PATCH 10/12] feat(workers): deploy multi-module build output --- README.md | 2 +- schemas/worker-project.v1.schema.json | 6 +- skills/xapi/guides/workers.md | 48 ++- src/commands/workers.ts | 44 +-- src/tests/skill-workers-guide.test.ts | 6 +- src/tests/workers-artifact.test.ts | 116 +++++++ src/tests/workers-billing-output.test.ts | 2 +- src/tests/workers-client.test.ts | 36 +++ src/tests/workers-project.test.ts | 15 + src/tests/workers-push.test.ts | 83 +++++- src/workers-artifact.ts | 365 +++++++++++++++++++++++ src/workers-client.ts | 3 +- src/workers-plan-output.ts | 2 +- src/workers-plan.ts | 37 +-- src/workers-project.ts | 1 + src/workers-push.ts | 116 +------ 16 files changed, 723 insertions(+), 159 deletions(-) create mode 100644 src/tests/workers-artifact.test.ts create mode 100644 src/workers-artifact.ts diff --git a/README.md b/README.md index f88002c..7c20e13 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ xapi-to workers create \ --preview-budget 0.25 \ --production-budget 2 -# Build locally or in CI, then upload the single bundled ES module. +# Upload one bundled ES module, or use --file dist/ --main worker.js for code splitting. xapi-to workers upload \ --file dist/worker.mjs \ --idempotency-key artifact-v1 diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json index 9bd7de7..b8f8372 100644 --- a/schemas/worker-project.v1.schema.json +++ b/schemas/worker-project.v1.schema.json @@ -32,7 +32,11 @@ "required": ["command", "output"], "properties": { "command": { "type": "string", "minLength": 1, "maxLength": 1000 }, - "output": { "$ref": "#/$defs/projectPath" } + "output": { "$ref": "#/$defs/projectPath" }, + "main": { + "description": "Entrypoint relative to build.output when output is a directory", + "$ref": "#/$defs/projectPath" + } } }, "environments": { diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 1dd6bea..1f5bfc7 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -83,6 +83,26 @@ Artifact, deploys preview, waits for the active state, and runs the configured health check. `push` never deletes an extra stateful resource or Secret; `plan` marks such drift `MANUAL` for explicit handling. +`build.output` may point to one bundled JavaScript module or to a directory of +Cloudflare code modules. A directory requires `build.main`, relative to that +directory, so CI and local runs select the same entrypoint: + +```json +{ + "build": { + "command": "npm run build", + "output": "dist", + "main": "worker.js" + } +} +``` + +Directory Artifacts include `.js`, `.mjs`, `.wasm`, `.txt`, and `.bin` modules +in one versioned upload. Relative imports must resolve inside the directory; +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. + After real preview validation, promote the exact active preview Artifact without rebuilding it: @@ -154,7 +174,7 @@ Each environment also returns `publicUrl`. Use that field for calls: xAPI may po ### Produce an Artifact, then deploy -Deployments consume an immutable xAPI Artifact, never a mutable directory and never a running Sandbox. The normal path is to bundle locally or in CI and upload one ES module. Sandbox is an optional build provider. +Deployments consume an immutable xAPI Artifact, never a mutable directory and never a running Sandbox. The normal path is to build locally or in CI and upload either one bundled ES module or one code-module directory. Sandbox is an optional build provider. The user's API key authenticates only xAPI control-plane requests. It is never embedded in a bundle, written into a build Sandbox, or sent directly to Cloudflare. Do not put runtime secrets in source; add them later as encrypted Secret bindings. @@ -167,7 +187,8 @@ export default { }; ``` -Build locally or in CI, then upload the single bundled UTF-8 ES module. It must be at most 1 MiB and include its runtime dependencies: +Build locally or in CI. A single bundled UTF-8 ES module must be at most 1 MiB +and include its runtime dependencies: ```bash npm run build @@ -176,6 +197,24 @@ npx xapi-to workers upload \ --idempotency-key artifact-2026-08-21 ``` +For code splitting, upload the output directory and name its entrypoint. The +directory may contain at most 200 supported modules and 10 MiB of decoded +module content. All modules are sent together in one xAPI Artifact request: + +```bash +npx xapi-to workers upload \ + --file dist/ \ + --main worker.js \ + --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. + Save the returned Artifact `id`, then deploy that exact Artifact to preview: ```bash @@ -199,7 +238,8 @@ curl "/health" The CLI skips `.git`, `.xapi`, `node_modules`, `dist`, credential directories (`.ssh`, `.aws`, `.gnupg`, `.docker`), `.env*`, package-manager credential files, private-key extensions, and common SSH private-key names. -`--output` must identify the single bundled ES module produced by the command: +The optional server-side Sandbox builder currently requires `--output` to +identify one bundled ES module produced by the command: ```bash npx xapi-to workers build \ @@ -212,7 +252,7 @@ npx xapi-to workers build \ The result must have `status: SUCCEEDED`. Save its `artifactId`, not the build `id`, and deploy it with `--artifact`. -Reuse an upload key only for identical bundle bytes. Reuse a build key only for the exact same source snapshot and parameters. Reuse a deployment key only for the same Artifact and environment. A successful deployment returns `status: ACTIVE`; `DEPLOYING` is not completion and `FAILED` must be surfaced with its error. +Reuse an upload key only for identical normalized Artifact bytes. Reuse a build key only for the exact same source snapshot and parameters. Reuse a deployment key only for the same Artifact and environment. A successful deployment returns `status: ACTIVE`; `DEPLOYING` is not completion and `FAILED` must be surfaced with its error. After real preview validation, deploy the identical file to production with a new stable key: diff --git a/src/commands/workers.ts b/src/commands/workers.ts index db4ad6d..419ecd2 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -24,6 +24,10 @@ import { promoteWorkerProject } from "../workers-promote.ts"; import { rollbackWorkerProject } from "../workers-rollback.ts"; import { readWorkerLogs, tailWorkerLogs } from "../workers-logs.ts"; import { formatWorkerMetering } from "../workers-metering-output.ts"; +import { + loadWorkerArtifact, + WorkerArtifactError, +} from "../workers-artifact.ts"; import { printWorkerBillingResponse, workerBillingOutputMode, @@ -44,7 +48,7 @@ COMMANDS list get create --name NAME --slug SLUG --preview-budget USD --production-budget USD - upload --file dist/index.mjs + upload --file dist/index.mjs|dist/ [--main worker.js] artifacts build --project . --entrypoint src/index.ts --command "npm run build" builds @@ -135,7 +139,8 @@ BILLING FLAGS --cursor OPAQUE --limit 1..100 Page ledger without decoding its cursor UPLOAD FLAGS - --file PATH Bundled UTF-8 ES module (required, max 1 MiB) + --file PATH Single ES module or code-module directory (required) + --main PATH Entrypoint relative to --file when it is a directory --idempotency-key KEY Stable retry key (generated when omitted) OPTIONAL SANDBOX BUILD FLAGS @@ -177,6 +182,7 @@ EXAMPLES xapi-to workers create --name "Daily agent" --slug daily-agent \ --preview-budget 0.25 --production-budget 2 xapi-to workers upload --file dist/index.mjs + xapi-to workers upload --file dist/ --main worker.js xapi-to workers deploy --artifact --env preview xapi-to workers billing overview --env production xapi-to workers billing usage --env production --json @@ -322,23 +328,6 @@ async function projectFiles(project: string) { return files; } -async function bundledModule(path: string) { - const absolute = resolve(path); - const info = await stat(absolute).catch((error) => { - err(`cannot read Worker bundle: ${absolute}`, error.message); - }); - if (!info.isFile()) err(`Worker bundle is not a file: ${absolute}`); - if (!info.size) err("Worker bundle is empty"); - if (info.size > 1024 * 1024) - err("Worker bundle exceeds the 1 MiB artifact limit"); - const buffer = await readFile(absolute); - const moduleCode = buffer.toString("utf8"); - if (!Buffer.from(moduleCode, "utf8").equals(buffer)) { - err("Worker bundle must be valid UTF-8 JavaScript"); - } - return moduleCode; -} - export async function workersCommand( args: string[], flags: Record, @@ -666,14 +655,27 @@ export async function workersCommand( return; } case "upload": { - assertFlags(flags, ["file", "idempotency-key"]); + assertFlags(flags, ["file", "main", "idempotency-key"]); const id = oneId( rest, "usage: xapi-to workers upload --file PATH", ); + if (flags.main === "true" || flags.main === "") { + err("--main requires a path relative to the output directory"); + } + let artifact; + try { + artifact = loadWorkerArtifact( + resolve(required(flags.file, "--file")), + flags.main, + ); + } catch (error) { + if (error instanceof WorkerArtifactError) err(error.message); + throw error; + } output( await client.uploadWorkerArtifact(options(), id, { - moduleCode: await bundledModule(required(flags.file, "--file")), + ...artifact.upload, idempotencyKey: flags["idempotency-key"] || randomUUID(), }), ); diff --git a/src/tests/skill-workers-guide.test.ts b/src/tests/skill-workers-guide.test.ts index 3b33422..9b1ecf6 100644 --- a/src/tests/skill-workers-guide.test.ts +++ b/src/tests/skill-workers-guide.test.ts @@ -37,13 +37,17 @@ describe('bundled xAPI Workers skill guide', () => { expect(guide).toContain('workers deploy '); expect(guide).toContain('--artifact '); expect(guide).toContain('### Optional Sandbox build'); + 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).not.toContain('--build '); }); it('preserves key isolation, idempotency, budgets, and terminal status rules', () => { expect(guide).toContain('never embedded in a bundle'); expect(guide).toContain('between $0.10 and $100 per day'); - expect(guide).toContain('Reuse an upload key only for identical bundle bytes'); + expect(guide).toContain('Reuse an upload key only for identical normalized Artifact bytes'); expect(guide).toContain('`status: ACTIVE`'); }); diff --git a/src/tests/workers-artifact.test.ts b/src/tests/workers-artifact.test.ts new file mode 100644 index 0000000..837105e --- /dev/null +++ b/src/tests/workers-artifact.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadWorkerArtifact, + WorkerArtifactError, +} from "../workers-artifact.ts"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function directory(): string { + const root = mkdtempSync(join(tmpdir(), "xapi-worker-artifact-")); + roots.push(root); + return root; +} + +describe("Worker Artifact loader", () => { + test("keeps the legacy single-module request and raw-byte hash", () => { + const root = directory(); + const source = "export default { fetch() { return new Response('ok') } };"; + const path = join(root, "worker.mjs"); + writeFileSync(path, source); + + const artifact = loadWorkerArtifact(path); + + expect(artifact.kind).toBe("module"); + expect(artifact.upload).toEqual({ moduleCode: source }); + expect(artifact.contentSha256).toBe( + createHash("sha256").update(source).digest("hex"), + ); + }); + + test("builds a deterministic multi-module request and server-compatible hash", () => { + const root = directory(); + mkdirSync(join(root, "chunks")); + writeFileSync( + join(root, "worker.js"), + 'import { answer } from "./chunks/answer.js"; import data from "./data.bin"; export default { fetch() { return Response.json({ answer, size: data.byteLength }) } };', + ); + writeFileSync(join(root, "chunks/answer.js"), "export const answer = 42;"); + writeFileSync(join(root, "data.bin"), Buffer.from([0, 1, 2, 255])); + + const artifact = loadWorkerArtifact(root, "worker.js"); + expect(artifact.kind).toBe("bundle"); + if (!("bundle" in artifact.upload)) throw new Error("expected bundle"); + expect(artifact.upload.bundle.modules.map((item) => item.path)).toEqual([ + "chunks/answer.js", + "data.bin", + "worker.js", + ]); + expect(artifact.upload.bundle.modules[1]).toEqual( + expect.objectContaining({ + path: "data.bin", + encoding: "base64", + content: "AAEC/w==", + contentType: "application/octet-stream", + }), + ); + const stored = Buffer.from( + JSON.stringify({ + version: 1, + mainModule: "worker.js", + modules: artifact.upload.bundle.modules.map((module) => ({ + path: module.path, + contentBase64: + module.encoding === "base64" + ? module.content + : Buffer.from(module.content, "utf8").toString("base64"), + contentType: module.contentType, + })), + }), + "utf8", + ); + expect(artifact.contentSha256).toBe( + createHash("sha256").update(stored).digest("hex"), + ); + expect(artifact.sizeBytes).toBe(stored.length); + }); + + test("requires an explicit directory entrypoint", () => { + const root = directory(); + writeFileSync(join(root, "worker.js"), "export default {};"); + expect(() => loadWorkerArtifact(root)).toThrow(WorkerArtifactError); + expect(() => loadWorkerArtifact(root)).toThrow("--main"); + }); + + test("rejects missing relative modules and static website assets", () => { + const root = directory(); + writeFileSync( + join(root, "worker.js"), + 'import "./missing.js"; export default {};', + ); + expect(() => loadWorkerArtifact(root, "worker.js")).toThrow( + "imports that are not in the Artifact", + ); + + writeFileSync(join(root, "missing.js"), "export {};"); + writeFileSync(join(root, "index.html"), "

site

"); + expect(() => loadWorkerArtifact(root, "worker.js")).toThrow( + "static-assets workflow", + ); + }); +}); diff --git a/src/tests/workers-billing-output.test.ts b/src/tests/workers-billing-output.test.ts index 2c2b100..aca4d89 100644 --- a/src/tests/workers-billing-output.test.ts +++ b/src/tests/workers-billing-output.test.ts @@ -109,7 +109,7 @@ describe("Worker billing human output", () => { Reasons LOW_BALANCE Resource totals - Resource Count Settled Reserved Estimated Exposure Quality Reasons +${' Resource Count Settled Reserved Estimated Exposure Quality Reasons'.padEnd(107)} ────────────── ───── ─────────── ──────── ───────── ──────── ───────────── ─────────────────────── worker_runtime 1 $1.25000000 — $0 — INDETERMINATE ESTIMATED_TOTAL_UNKNOWN ────────────────────────────────────────────────────────────────────────────────────────`); diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts index 91975c2..f99476e 100644 --- a/src/tests/workers-client.test.ts +++ b/src/tests/workers-client.test.ts @@ -126,6 +126,42 @@ describe("workers client", () => { expect(JSON.parse(init.body).idempotencyKey).toBe("showcase-upload-v1"); }); + it("uploads a multi-module bundle in one immutable artifact request", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "artifact-2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + const bundle = { + version: 1 as const, + mainModule: "worker.js", + modules: [ + { + path: "worker.js", + content: 'import "./chunk.js"; export default {};', + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }, + { + path: "chunk.js", + content: "export {};", + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }, + ], + }; + await uploadWorkerArtifact(options, "worker/id", { + bundle, + idempotencyKey: "showcase-bundle-v1", + }); + const [, init] = fetchSpy.mock.calls[0] as any[]; + expect(JSON.parse(init.body)).toEqual({ + bundle, + idempotencyKey: "showcase-bundle-v1", + }); + }); + it("uses the server-side build endpoint with an extended timeout", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ id: "build-1", status: "SUCCEEDED" }), { diff --git a/src/tests/workers-project.test.ts b/src/tests/workers-project.test.ts index 40b48ae..1c1e7ea 100644 --- a/src/tests/workers-project.test.ts +++ b/src/tests/workers-project.test.ts @@ -145,6 +145,21 @@ describe("Worker project configuration", () => { ).toBe(join(root, "dist", "worker.mjs")); }); + test("accepts an explicit main module for directory build output", () => { + const root = fixture({ + build: { + command: "npm run build", + output: "dist", + main: "worker.js", + }, + }); + expect(loadWorkerProject(root).config.build).toEqual({ + command: "npm run build", + output: "dist", + main: "worker.js", + }); + }); + test("throws a stable error when no project exists", () => { const root = realpathSync( mkdtempSync(join(tmpdir(), "xapi-worker-empty-")), diff --git a/src/tests/workers-push.test.ts b/src/tests/workers-push.test.ts index f8db129..ece248a 100644 --- a/src/tests/workers-push.test.ts +++ b/src/tests/workers-push.test.ts @@ -192,12 +192,30 @@ function fakePlatform( listWorkerArtifacts: async () => state.artifacts, uploadWorkerArtifact: async (_api, _id, input) => { calls.uploadArtifact += 1; - const moduleCode = String(input.moduleCode); + const artifactBytes = "bundle" in input + ? Buffer.from( + JSON.stringify({ + version: 1, + mainModule: input.bundle.mainModule, + modules: [...input.bundle.modules] + .sort((a: any, b: any) => a.path.localeCompare(b.path)) + .map((module: any) => ({ + path: module.path, + contentBase64: + module.encoding === "base64" + ? module.content + : Buffer.from(module.content, "utf8").toString("base64"), + contentType: module.contentType, + })), + }), + "utf8", + ) + : Buffer.from(input.moduleCode, "utf8"); const artifact = { id: "artifact-1", idempotencyKey: input.idempotencyKey, - contentSha256: createHash("sha256").update(moduleCode).digest("hex"), - sizeBytes: Buffer.byteLength(moduleCode), + contentSha256: createHash("sha256").update(artifactBytes).digest("hex"), + sizeBytes: artifactBytes.length, }; state.artifacts.push(artifact); if (failArtifact) { @@ -511,7 +529,7 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(platform.calls.deploy).toBe(0); }); - test("rejects an unbundled module before Artifact upload and preserves remote state", async () => { + test("rejects a single-file output with an unresolved import before upload", async () => { const root = fixture({ linked: true }); const platform = fakePlatform({ exists: true }); let caught: WorkerPushError | undefined; @@ -534,11 +552,66 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); caught = error as WorkerPushError; } expect(caught).toBeInstanceOf(WorkerPushError); - expect(caught?.message).toContain("single self-contained module"); + expect(caught?.message).toContain("imports that are not in the Artifact"); expect(caught?.recovery).toEqual( expect.objectContaining({ workerId, resourcesPreserved: true }), ); expect(platform.calls.uploadArtifact).toBe(0); expect(platform.calls.deploy).toBe(0); }); + + test("uploads a code-split directory as one immutable Artifact", async () => { + const root = fixture({ linked: true }); + const configPath = join(root, "xapi.worker.json"); + const config = JSON.parse(readFileSync(configPath, "utf8")); + config.build = { + command: "fake-build", + output: "dist", + main: "worker.mjs", + }; + writeFileSync(configPath, JSON.stringify(config, null, 2)); + const platform = fakePlatform({ exists: true }); + let uploadInput: Record | undefined; + const upload = platform.client.uploadWorkerArtifact; + platform.client.uploadWorkerArtifact = async (...args) => { + uploadInput = args[2]; + return upload(...args); + }; + + const result = await pushWorkerProject({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: platform.client, + confirm: async () => true, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + `import { message } from "./chunk.mjs"; export default { fetch() { return new Response(message) } };`, + ); + writeFileSync( + join(root, "dist/chunk.mjs"), + `export const message = "ok";`, + ); + }, + fetchPublic: (async () => new Response("ok")) as unknown as typeof fetch, + sleep: async () => undefined, + }); + + expect(result.status).toBe("ACTIVE"); + expect(uploadInput).not.toHaveProperty("moduleCode"); + expect(uploadInput?.bundle).toEqual( + expect.objectContaining({ + version: 1, + mainModule: "worker.mjs", + modules: expect.arrayContaining([ + expect.objectContaining({ path: "worker.mjs" }), + expect.objectContaining({ path: "chunk.mjs" }), + ]), + }), + ); + expect(platform.calls.uploadArtifact).toBe(1); + expect(platform.calls.deploy).toBe(1); + }); }); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts new file mode 100644 index 0000000..1f872a1 --- /dev/null +++ b/src/workers-artifact.ts @@ -0,0 +1,365 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { posix, relative, resolve, sep } from "node:path"; +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 SAFE_MODULE_PATH = + /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@+/-]{1,240}$/; + +type UnknownRecord = Record; + +export type WorkerModuleContentType = + | "application/javascript+module" + | "application/wasm" + | "text/plain" + | "application/octet-stream"; + +export interface WorkerArtifactBundleModule { + path: string; + content: string; + encoding: "utf8" | "base64"; + contentType: WorkerModuleContentType; +} + +export interface WorkerArtifactBundle { + version: 1; + mainModule: string; + modules: WorkerArtifactBundleModule[]; +} + +export type WorkerArtifactUploadInput = + | { moduleCode: string } + | { bundle: WorkerArtifactBundle }; + +export type WorkerArtifactUploadRequest = WorkerArtifactUploadInput & { + idempotencyKey: string; +}; + +export interface LoadedWorkerArtifact { + kind: "module" | "bundle"; + contentSha256: string; + sizeBytes: number; + upload: WorkerArtifactUploadInput; +} + +export class WorkerArtifactError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkerArtifactError"; + } +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function walkSyntax(node: unknown, visit: (item: UnknownRecord) => void): void { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { + for (const item of node) walkSyntax(item, visit); + return; + } + const item = node as UnknownRecord; + if (typeof item.type === "string") visit(item); + for (const [key, child] of Object.entries(item)) { + if (key !== "start" && key !== "end" && key !== "loc") { + walkSyntax(child, visit); + } + } +} + +function literalSource(node: unknown): string | undefined { + if (!node || typeof node !== "object") return undefined; + const value = (node as UnknownRecord).value; + return typeof value === "string" && value ? value : undefined; +} + +function validateJavaScript( + modulePath: string, + source: string, + bundledPaths?: ReadonlySet, + requireDefaultExport = false, +): void { + let ast: unknown; + try { + ast = parse(source, { + ecmaVersion: "latest", + sourceType: "module", + allowHashBang: true, + }); + } catch (error) { + throw new WorkerArtifactError( + `Worker module is not valid JavaScript ESM (${modulePath}): ${error instanceof Error ? error.message : "parse failed"}`, + ); + } + + let defaultExport = false; + const imports: string[] = []; + walkSyntax(ast, (node) => { + if (node.type === "ExportDefaultDeclaration") defaultExport = true; + if (node.type === "ExportNamedDeclaration" && Array.isArray(node.specifiers)) { + defaultExport ||= node.specifiers.some((specifier) => { + if (!specifier || typeof specifier !== "object") return false; + const exported = (specifier as UnknownRecord).exported; + return ( + !!exported && + typeof exported === "object" && + ((exported as UnknownRecord).name === "default" || + (exported as UnknownRecord).value === "default") + ); + }); + } + if ( + node.type === "ImportDeclaration" || + node.type === "ExportAllDeclaration" || + (node.type === "ExportNamedDeclaration" && node.source) + ) { + const sourceValue = literalSource(node.source); + if (sourceValue) imports.push(sourceValue); + } + if (node.type === "ImportExpression") { + imports.push(literalSource(node.source) || ""); + } + }); + + if (requireDefaultExport && !defaultExport) { + throw new WorkerArtifactError( + `Worker entrypoint must export a default Cloudflare Worker handler: ${modulePath}`, + ); + } + + const unresolved = imports.filter((specifier) => { + if (/^(?:cloudflare|node):/.test(specifier)) return false; + if (!bundledPaths) return true; + if (!specifier.startsWith("./") && !specifier.startsWith("../")) { + return true; + } + const resolved = posix.normalize( + posix.join(posix.dirname(modulePath), specifier), + ); + return ( + resolved === ".." || + resolved.startsWith("../") || + !bundledPaths.has(resolved) + ); + }); + if (unresolved.length) { + throw new WorkerArtifactError( + `Worker module has imports that are not in the Artifact (${modulePath}): ${[...new Set(unresolved)].sort().join(", ")}. Bundle package dependencies or include every relative module in the output directory.`, + ); + } +} + +function moduleContentType(path: string): WorkerModuleContentType | undefined { + const extension = posix.extname(path).toLowerCase(); + if (extension === ".js" || extension === ".mjs") { + return "application/javascript+module"; + } + if (extension === ".wasm") return "application/wasm"; + if (extension === ".txt") return "text/plain"; + if (extension === ".bin") return "application/octet-stream"; + return undefined; +} + +function portableRelativePath(root: string, path: string): string { + return relative(root, path).split(sep).join("/"); +} + +function normalizeMainModule(value: string | undefined): string { + if (!value) { + throw new WorkerArtifactError( + "build.main (or --main) is required when the Worker build output is a directory", + ); + } + if (value.includes("\\") || !SAFE_MODULE_PATH.test(value)) { + throw new WorkerArtifactError( + "Worker bundle main module must be a portable path inside the output directory", + ); + } + return posix.normalize(value); +} + +function loadSingleModule(path: string): LoadedWorkerArtifact { + const info = statSync(path); + if (!info.isFile()) { + throw new WorkerArtifactError("Worker build output is not a file or directory"); + } + if (!info.size) throw new WorkerArtifactError("Worker build output is empty"); + if (info.size > MAX_LEGACY_ARTIFACT_BYTES) { + throw new WorkerArtifactError( + "Single-file Worker output exceeds the 1 MiB artifact limit; use a code-split output directory when appropriate", + ); + } + const bytes = readFileSync(path); + const moduleCode = bytes.toString("utf8"); + if (!Buffer.from(moduleCode, "utf8").equals(bytes)) { + throw new WorkerArtifactError( + "Single-file Worker output must be valid UTF-8 JavaScript", + ); + } + validateJavaScript(portableRelativePath(resolve(path, ".."), path), moduleCode, undefined, true); + return { + kind: "module", + contentSha256: sha256(bytes), + sizeBytes: bytes.length, + upload: { moduleCode }, + }; +} + +function collectBundleFiles(root: string): Array<{ + path: string; + bytes: Buffer; + contentType: WorkerModuleContentType; +}> { + const files: Array<{ + path: string; + bytes: Buffer; + contentType: WorkerModuleContentType; + }> = []; + 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( + `Worker output must not contain symbolic links: ${relativePath}`, + ); + } + if (info.isDirectory()) { + walk(absolute); + continue; + } + if (!info.isFile()) { + throw new WorkerArtifactError( + `Worker output contains an unsupported filesystem entry: ${relativePath}`, + ); + } + if (!SAFE_MODULE_PATH.test(relativePath)) { + throw new WorkerArtifactError( + `Worker module path is invalid: ${relativePath}`, + ); + } + const contentType = moduleContentType(relativePath); + if (!contentType) { + throw new WorkerArtifactError( + `Worker output contains an unsupported module file: ${relativePath}. Code bundles support .js, .mjs, .wasm, .txt, and .bin; publish website assets through the static-assets workflow.`, + ); + } + files.push({ path: relativePath, bytes: readFileSync(absolute), contentType }); + if (files.length > MAX_BUNDLE_MODULES) { + throw new WorkerArtifactError( + `Worker bundle exceeds the ${MAX_BUNDLE_MODULES} module limit`, + ); + } + } + }; + walk(root); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerArtifact { + const mainModule = normalizeMainModule(main); + const files = collectBundleFiles(root); + if (!files.length) throw new WorkerArtifactError("Worker output directory is empty"); + const totalBytes = files.reduce((sum, file) => sum + file.bytes.length, 0); + if (totalBytes > MAX_BUNDLE_CONTENT_BYTES) { + throw new WorkerArtifactError( + `Worker bundle contents exceed the ${MAX_BUNDLE_CONTENT_BYTES} byte limit`, + ); + } + const paths = new Set(files.map((file) => file.path)); + const mainFile = files.find((file) => file.path === mainModule); + if (!mainFile) { + throw new WorkerArtifactError( + `Worker bundle entrypoint does not exist in the output directory: ${mainModule}`, + ); + } + if (mainFile.contentType !== "application/javascript+module") { + throw new WorkerArtifactError( + `Worker bundle entrypoint must be a .js or .mjs module: ${mainModule}`, + ); + } + + for (const file of files) { + const textModule = + file.contentType === "application/javascript+module" || + file.contentType === "text/plain"; + if (!textModule) continue; + const source = file.bytes.toString("utf8"); + if (!Buffer.from(source, "utf8").equals(file.bytes)) { + throw new WorkerArtifactError( + `Text Worker module must be valid UTF-8: ${file.path}`, + ); + } + if (file.contentType === "application/javascript+module") { + validateJavaScript(file.path, source, paths, file.path === mainModule); + } + } + + const bundle: WorkerArtifactBundle = { + version: 1, + mainModule, + modules: files.map((file) => { + const utf8 = + file.contentType === "application/javascript+module" || + file.contentType === "text/plain"; + return { + path: file.path, + content: utf8 ? file.bytes.toString("utf8") : file.bytes.toString("base64"), + encoding: utf8 ? "utf8" : "base64", + contentType: file.contentType, + }; + }), + }; + const storedBytes = Buffer.from( + JSON.stringify({ + version: 1, + mainModule, + modules: files.map((file) => ({ + path: file.path, + contentBase64: file.bytes.toString("base64"), + contentType: file.contentType, + })), + }), + "utf8", + ); + return { + kind: "bundle", + contentSha256: sha256(storedBytes), + sizeBytes: storedBytes.length, + upload: { bundle }, + }; +} + +export function loadWorkerArtifact( + outputPath: string, + mainModule?: string, +): LoadedWorkerArtifact { + if (!existsSync(outputPath)) { + throw new WorkerArtifactError(`Worker build output does not exist: ${outputPath}`); + } + if (lstatSync(outputPath).isSymbolicLink()) { + throw new WorkerArtifactError("Worker build output must not be a symbolic link"); + } + const info = statSync(outputPath); + if (info.isFile()) { + if (mainModule) { + throw new WorkerArtifactError( + "build.main (or --main) is only valid when the Worker build output is a directory", + ); + } + return loadSingleModule(outputPath); + } + if (info.isDirectory()) return loadModuleBundle(outputPath, mainModule); + throw new WorkerArtifactError("Worker build output is not a file or directory"); +} diff --git a/src/workers-client.ts b/src/workers-client.ts index 5fc97f3..9674ed4 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -1,4 +1,5 @@ import { request } from "./client.ts"; +import type { WorkerArtifactUploadRequest } from "./workers-artifact.ts"; import { scheme } from "./config.ts"; export interface WorkersClientOptions { @@ -126,7 +127,7 @@ export function listWorkerArtifacts(options: WorkersClientOptions, id: string) { export function uploadWorkerArtifact( options: WorkersClientOptions, id: string, - input: Record, + input: WorkerArtifactUploadRequest, ) { return request( url(options, `/${encodeURIComponent(id)}/artifacts`), diff --git a/src/workers-plan-output.ts b/src/workers-plan-output.ts index 51ceb82..bc64e42 100644 --- a/src/workers-plan-output.ts +++ b/src/workers-plan-output.ts @@ -204,7 +204,7 @@ export function formatWorkerPlan(plan: WorkerDeploymentPlan): string { metadataRow("Config", plan.project.configPath), metadataRow( "Build", - `${plan.project.build.command} → ${plan.project.build.output}`, + `${plan.project.build.command} → ${plan.project.build.output}${plan.project.build.main ? ` (main: ${plan.project.build.main})` : ""}`, ), " Plan compares the current bundle; push rebuilds it before upload.", "", diff --git a/src/workers-plan.ts b/src/workers-plan.ts index 6e5cf4c..b217601 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -1,7 +1,7 @@ -import { createHash } from "node:crypto"; -import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; +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 { deploymentPrefix, currentMatchingDeployment } from "./workers-deployment-state.ts"; import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; import { @@ -43,7 +43,7 @@ export interface WorkerDeploymentPlan { configPath: string; workerId?: string; slug: string; - build: { command: string; output: string }; + build: { command: string; output: string; main?: string }; }; environment: "preview" | "production"; remote: { linked: boolean; workerId?: string }; @@ -365,24 +365,18 @@ function localArtifact(project: LoadedWorkerProject): { "build.output", ); if (!existsSync(path)) return {}; - if (lstatSync(path).isSymbolicLink()) { - return { blocked: "Build output is a symbolic link" }; - } - const info = statSync(path); - if (!info.isFile()) return { blocked: "Build output is not a file" }; - if (!info.size) return { blocked: "Build output is empty" }; - if (info.size > 1024 * 1024) { - return { blocked: "Build output exceeds the 1 MiB artifact limit" }; - } - const bytes = readFileSync(path); - const source = bytes.toString("utf8"); - if (!Buffer.from(source, "utf8").equals(bytes)) { - return { blocked: "Build output is not valid UTF-8 JavaScript" }; + try { + const artifact = loadWorkerArtifact(path, project.config.build.main); + return { + sha256: artifact.contentSha256, + sizeBytes: artifact.sizeBytes, + }; + } catch (error) { + if (error instanceof WorkerArtifactError) { + return { blocked: error.message }; + } + throw error; } - return { - sha256: createHash("sha256").update(bytes).digest("hex"), - sizeBytes: info.size, - }; } function validatePlanInputs(project: LoadedWorkerProject): void { @@ -465,7 +459,7 @@ function artifactAndDeployment( local.sha256 || project.config.build.output, local.sha256 ? "Upload the local bundle as a new immutable Artifact" - : `Run ${project.config.build.command} and upload its single ESM output`, + : `Run ${project.config.build.command} and upload its Worker output`, { output: project.config.build.output, ...(local.sha256 @@ -702,6 +696,7 @@ export async function createWorkerPlan( build: { command: project.config.build.command, output: project.config.build.output, + ...(project.config.build.main ? { main: project.config.build.main } : {}), }, }, environment: options.environment, diff --git a/src/workers-project.ts b/src/workers-project.ts index 93f5181..e792e06 100644 --- a/src/workers-project.ts +++ b/src/workers-project.ts @@ -127,6 +127,7 @@ export const workerProjectConfigSchema = z .object({ command: z.string().min(1).max(1000), output: relativeProjectPath, + main: relativeProjectPath.optional(), }) .strict(), environments: z diff --git a/src/workers-push.ts b/src/workers-push.ts index 512fb7a..01ba25f 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -1,7 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, - lstatSync, readFileSync, renameSync, statSync, @@ -10,8 +9,13 @@ import { } from "node:fs"; import { spawn } from "node:child_process"; import { createInterface } from "node:readline/promises"; -import { parse } from "acorn"; import { HttpError, isRetryableRequestError } from "./client.ts"; +import { + type LoadedWorkerArtifact, + loadWorkerArtifact, + WorkerArtifactError, + type WorkerArtifactUploadRequest, +} from "./workers-artifact.ts"; import type { WorkersClientOptions } from "./workers-client.ts"; import * as workersClient from "./workers-client.ts"; import { @@ -73,7 +77,7 @@ export interface PushClient extends PlanClient, DeploymentClient { uploadWorkerArtifact( options: WorkersClientOptions, id: string, - input: Record, + input: WorkerArtifactUploadRequest, ): Promise; } @@ -240,112 +244,20 @@ async function terminalConfirm(): Promise { } } -function walkSyntax(node: unknown, visit: (item: UnknownRecord) => void): void { - if (!node || typeof node !== "object") return; - if (Array.isArray(node)) { - for (const item of node) walkSyntax(item, visit); - return; - } - const item = node as UnknownRecord; - if (typeof item.type === "string") visit(item); - for (const [key, child] of Object.entries(item)) { - if (key !== "start" && key !== "end" && key !== "loc") { - walkSyntax(child, visit); - } - } -} - -function validateBundle(project: LoadedWorkerProject): { - moduleCode: string; - contentSha256: string; - sizeBytes: number; -} { +function validateBundle(project: LoadedWorkerProject): LoadedWorkerArtifact { const path = resolveWorkerProjectPath( project, project.config.build.output, "build.output", ); - if (!existsSync(path)) { - throw new WorkerPushError(`Build output does not exist: ${path}`); - } - if (lstatSync(path).isSymbolicLink()) { - throw new WorkerPushError("Build output must not be a symbolic link"); - } - const info = statSync(path); - if (!info.isFile()) throw new WorkerPushError("Build output is not a file"); - if (!info.size) throw new WorkerPushError("Build output is empty"); - if (info.size > 1024 * 1024) { - throw new WorkerPushError("Build output exceeds the 1 MiB artifact limit"); - } - const bytes = readFileSync(path); - const moduleCode = bytes.toString("utf8"); - if (!Buffer.from(moduleCode, "utf8").equals(bytes)) { - throw new WorkerPushError("Build output must be valid UTF-8 JavaScript"); - } - let ast: unknown; try { - ast = parse(moduleCode, { - ecmaVersion: "latest", - sourceType: "module", - allowHashBang: true, - }); + return loadWorkerArtifact(path, project.config.build.main); } catch (error) { - throw new WorkerPushError( - `Build output is not valid JavaScript ESM: ${error instanceof Error ? error.message : "parse failed"}`, - ); - } - let defaultExport = false; - const externalImports: string[] = []; - walkSyntax(ast, (node) => { - if (node.type === "ExportDefaultDeclaration") defaultExport = true; - if ( - node.type === "ExportNamedDeclaration" && - Array.isArray(node.specifiers) - ) { - defaultExport ||= node.specifiers.some((specifier) => { - if (!specifier || typeof specifier !== "object") return false; - const exported = (specifier as UnknownRecord).exported; - if (!exported || typeof exported !== "object") return false; - const exportedRecord = exported as UnknownRecord; - return exportedRecord.name === "default" || exportedRecord.value === "default"; - }); - } - if ( - node.type === "ImportDeclaration" || - node.type === "ExportAllDeclaration" || - (node.type === "ExportNamedDeclaration" && node.source) - ) { - const source = text(record(node.source, "module source").value); - if (source && !/^(?:cloudflare|node):/.test(source)) { - externalImports.push(source); - } - } - if (node.type === "ImportExpression") { - const sourceNode = node.source; - const source = - sourceNode && typeof sourceNode === "object" - ? text((sourceNode as UnknownRecord).value) - : undefined; - if (!source || !/^(?:cloudflare|node):/.test(source)) { - externalImports.push(source || ""); - } + if (error instanceof WorkerArtifactError) { + throw new WorkerPushError(error.message); } - }); - if (!defaultExport) { - throw new WorkerPushError( - "Build output must export a default Cloudflare Worker handler", - ); - } - if (externalImports.length) { - throw new WorkerPushError( - `Build output is not a single self-contained module; bundle these imports: ${[...new Set(externalImports)].sort().join(", ")}`, - ); + throw error; } - return { - moduleCode, - contentSha256: sha256(bytes), - sizeBytes: info.size, - }; } function environmentOf( @@ -604,7 +516,7 @@ async function ensureArtifact( try { return record( await api.uploadWorkerArtifact(options, workerId, { - moduleCode: bundle.moduleCode, + ...bundle.upload, idempotencyKey, }), "Artifact", @@ -615,7 +527,7 @@ async function ensureArtifact( if (reconciled) return reconciled; return record( await api.uploadWorkerArtifact(options, workerId, { - moduleCode: bundle.moduleCode, + ...bundle.upload, idempotencyKey, }), "Artifact", From 14d4c22901645d2db1c1baaa3740923f236ed726 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 14 Sep 2026 20:48:01 +0800 Subject: [PATCH 11/12] feat(workers): deploy native static assets --- README.md | 22 ++++ schemas/worker-project.v1.schema.json | 39 ++++++ skills/xapi/SKILL.md | 2 +- skills/xapi/guides/workers.md | 37 ++++-- src/tests/skill-workers-guide.test.ts | 4 +- src/tests/workers-artifact.test.ts | 31 +++++ src/tests/workers-project.test.ts | 17 +++ src/tests/workers-promote.test.ts | 41 +++++- src/tests/workers-wrangler-import.test.ts | 14 +++ src/workers-artifact.ts | 147 +++++++++++++++++++++- src/workers-plan-output.ts | 1 + src/workers-plan.ts | 37 +++++- src/workers-project.ts | 34 +++++ src/workers-promote.ts | 26 +++- src/workers-push.ts | 23 +++- src/workers-wrangler-import.ts | 42 ++++++- 16 files changed, 496 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7c20e13..419ea8e 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 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..bab0291 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -208,12 +208,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 +243,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/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-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..d02e951 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -12,6 +12,11 @@ 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}$/; @@ -30,10 +35,36 @@ 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[]; + assets?: WorkerArtifactAssets; } export type WorkerArtifactUploadInput = @@ -170,6 +201,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 +319,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 +401,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 +440,7 @@ function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerA } } + const assets = staticAssets ? collectAssetFiles(staticAssets) : undefined; const bundle: WorkerArtifactBundle = { version: 1, mainModule, @@ -320,7 +455,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 +467,7 @@ function loadModuleBundle(root: string, main: string | undefined): LoadedWorkerA contentBase64: file.bytes.toString("base64"), contentType: file.contentType, })), + ...(assets ? { assets } : {}), }), "utf8", ); @@ -344,6 +482,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 +497,10 @@ 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"); } 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..9a18494 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -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 = { @@ -366,7 +368,20 @@ function localArtifact(project: LoadedWorkerProject): { ); if (!existsSync(path)) return {}; try { - const artifact = loadWorkerArtifact(path, project.config.build.main); + const artifact = loadWorkerArtifact( + path, + project.config.build.main, + project.config.assets + ? { + ...project.config.assets, + directory: resolveWorkerProjectPath( + project, + project.config.assets.directory, + "assets.directory", + ), + } + : undefined, + ); return { sha256: artifact.contentSha256, sizeBytes: artifact.sizeBytes, @@ -667,6 +682,22 @@ export async function createWorkerPlan( prerequisiteBlocked = compareSecrets(actions, desired.secrets, remoteSecrets) || prerequisiteBlocked; + 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, + }); + } + } artifactAndDeployment( actions, project, 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..f239ac4 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -105,6 +105,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 }; } @@ -251,7 +252,20 @@ function validateBundle(project: LoadedWorkerProject): LoadedWorkerArtifact { "build.output", ); try { - return loadWorkerArtifact(path, project.config.build.main); + return loadWorkerArtifact( + 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); @@ -903,6 +917,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 +939,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"), From e493b6e4273f664d8eb6439238708fd37dfeda74 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Tue, 15 Sep 2026 08:46:14 +0800 Subject: [PATCH 12/12] feat(workers): publish complete Wrangler build bundles --- README.md | 8 ++ bun.lock | 12 +++ package.json | 2 + skills/xapi/guides/workers.md | 43 +++++++++ src/commands/workers.ts | 4 +- src/tests/workers-native-bundle.test.ts | 57 ++++++++++++ src/workers-artifact.ts | 112 +++++++++++++++++++++++- src/workers-plan.ts | 17 ++-- src/workers-push.ts | 12 +-- 9 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 src/tests/workers-native-bundle.test.ts diff --git a/README.md b/README.md index 419ea8e..9abf51d 100644 --- a/README.md +++ b/README.md @@ -672,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/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index bab0291..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: 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/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/workers-artifact.ts b/src/workers-artifact.ts index d02e951..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, @@ -18,7 +19,7 @@ const MAX_ASSET_FILE_BYTES = 25 * 1024 * 1024; // 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; @@ -64,6 +65,7 @@ export interface WorkerArtifactBundle { version: 1; mainModule: string; modules: WorkerArtifactBundleModule[]; + observability?: { enabled: boolean }; assets?: WorkerArtifactAssets; } @@ -80,6 +82,7 @@ export interface LoadedWorkerArtifact { contentSha256: string; sizeBytes: number; upload: WorkerArtifactUploadInput; + nativeMetadata?: UnknownRecord; } export class WorkerArtifactError extends Error { @@ -504,3 +507,110 @@ export function loadWorkerArtifact( 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.ts b/src/workers-plan.ts index 9a18494..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 { @@ -356,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, @@ -368,7 +368,7 @@ function localArtifact(project: LoadedWorkerProject): { ); if (!existsSync(path)) return {}; try { - const artifact = loadWorkerArtifact( + const artifact = await loadWorkerArtifactInput( path, project.config.build.main, project.config.assets @@ -382,6 +382,7 @@ function localArtifact(project: LoadedWorkerProject): { } : undefined, ); + validateNativeDeploymentMetadata(artifact, readWranglerDeploymentSettings(project, environment), project.config.environments[environment].resources); return { sha256: artifact.contentSha256, sizeBytes: artifact.sizeBytes, @@ -420,7 +421,7 @@ function validatePlanInputs(project: LoadedWorkerProject): void { } } -function artifactAndDeployment( +async function artifactAndDeployment( actions: WorkerPlanAction[], project: LoadedWorkerProject, remote: UnknownRecord | undefined, @@ -430,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, @@ -698,7 +699,7 @@ export async function createWorkerPlan( }); } } - artifactAndDeployment( + await artifactAndDeployment( actions, project, remote, diff --git a/src/workers-push.ts b/src/workers-push.ts index f239ac4..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"; @@ -245,14 +246,14 @@ 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( + return await loadWorkerArtifactInput( path, project.config.build.main, project.config.assets @@ -514,7 +515,7 @@ async function ensureArtifact( api: PushClient, options: WorkersClientOptions, workerId: string, - bundle: ReturnType, + bundle: Awaited>, ): Promise { const idempotencyKey = stableKey( "xapi-worker-artifact-v1", @@ -869,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,