From d79c33f5e542c3aee94ac3aabd7a333822df47be Mon Sep 17 00:00:00 2001 From: Garfield Date: Tue, 20 Oct 2026 21:17:07 +0800 Subject: [PATCH 1/2] fix(sdk): keep concurrent calls, waits and token refreshes independent Runtime checks of the 4.0.0 browser entry in platform-slide-editor found: - A call that joined another call's upload of the same File failed at once with that call's error when it was cancelled with its own reason or timed out; only a bare AbortError made it upload again. A joined upload that fails for any reason now leaves the caller to upload for itself, and the caller ends only by its own signal, also while it waits. - An event stream that failed at the network layer (no status) was retried every 5 s for up to 100 attempts, on top of fetch retries, instead of falling back to polling. Waiting now subscribes without reconnecting, so any stream failure falls back to polling at once; tasks.subscribe keeps reconnecting by default. - A 401 for a request sent before a shared refresh finished called onUnauthorized again. Requests record the token version they were sent with and retry once with a newer token without refreshing. A refresh that returns a bare token also no longer rewrites the retried request into the client's default space, which created a call-level-space task in the wrong space. - A 402 used a generic checkout message; it now carries the server's. - authUuid, df_uuid and the Node device id accept any 1-100 visible ASCII characters (the server hashes and stores them in a 100-character column), so an app's nanoid df_uuid is reused instead of overwritten. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- src/client/index.ts | 15 ++--- src/client/types.ts | 4 +- src/client/uploads.ts | 40 ++++++++++--- src/cloud/browser-auth-uuid.ts | 6 +- src/cloud/contracts.ts | 7 ++- src/cloud/device-id.ts | 11 ++++ src/cloud/errors.ts | 3 +- src/cloud/http-client.ts | 26 ++++++++- src/cloud/tasks.ts | 5 +- src/node/auth-uuid.ts | 7 +-- tests/browser/client.test.ts | 100 ++++++++++++++++++++++++++++++--- tests/browser/fake-cloud.mjs | 8 ++- tests/cloud/sdk.test.ts | 46 +++++---------- 14 files changed, 203 insertions(+), 81 deletions(-) create mode 100644 src/cloud/device-id.ts diff --git a/README.md b/README.md index 3feb606..33c3754 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,11 @@ json.content; json.schemaVersion; json.assets; Inputs are `File`, `{ file: Blob | Uint8Array | ArrayBuffer, name }` or `{ url }`; paths, unnamed Blobs and Node options are rejected before any request, and so is any option the client does not know (`usage_error` naming it). Results carry `content`, `format`, `pages`, `reusedParse`, `warnings`, `tasks` (`{ type, id }`) and `outputs`; `images` / `assets` are the matching outputs, and `RemoteFile.blob()` fetches one. Progress events belong to the call that made them: `upload` (0–1, at once 1 when the file is reused), `task` (on creation and each status change, `completed` before `download`) and `download`. -**Authentication.** Never put a server API key in browser code; the browser client has no `apiKey` option. Prefer a backend proxy that checks the user, tenant and `spaceId` on every operation. Connect directly only with a short-lived, user-scoped token. A 401 calls `onUnauthorized` once (concurrent 401s share the refresh); return a nonempty token for the same user and the request is retried once. A failed refresh rejects with `auth_error` and never falls back to a guest; requests that create tasks are never retried. The device id is `authUuid` when given, otherwise localStorage `df_uuid`, sent as `X-Auth-UUID`. +**Authentication.** Never put a server API key in browser code; the browser client has no `apiKey` option. Prefer a backend proxy that checks the user, tenant and `spaceId` on every operation. Connect directly only with a short-lived, user-scoped token. A 401 calls `onUnauthorized` once (concurrent 401s share the refresh, and a request sent before it simply retries with the new token); return a nonempty token for the same user and the request is retried once, in its own space. A failed refresh rejects with `auth_error` and never falls back to a guest; requests that create tasks are never retried. The device id is `authUuid` when given, otherwise localStorage `df_uuid` (created when missing), sent as `X-Auth-UUID`; any 1–100 visible ASCII characters will do, so an app's own UUID or nanoid is reused. -**Errors and cancellation.** Failures are `DeckOpsError` (`code`, `message`, `hint`, `taskId`, `details`); `isDeckOpsError(error, 'quota_error')` narrows them, and a quota error keeps the server body in `details` for a top-up dialog. A timeout carries the task id and `details.reason === 'timeout'`. Aborting rejects with the signal's own `reason` (an `AbortError` `DOMException` without one) and does not cancel a submitted cloud task; `ops.tasks.get(id, { spaceId })` reports it later. +**Errors and cancellation.** Failures are `DeckOpsError` (`code`, `message`, `hint`, `taskId`, `details`); `isDeckOpsError(error, 'quota_error')` narrows them, and a quota error carries the server's message and keeps its body in `details` for a top-up dialog. A timeout carries the task id and `details.reason === 'timeout'`. Aborting rejects with the signal's own `reason` (an `AbortError` `DOMException` without one) and does not cancel a submitted cloud task; `ops.tasks.get(id, { spaceId })` reports it later. -**CORS for direct access.** Allow your origin on the API (including `GET /tools/capabilities` and `PUT /files/:id/status/uploaded`), the SSE stream, signed upload URLs and result downloads; allow the `X-Auth-Token`, `X-Auth-UUID`, `Content-Type` and `response-event-stream` headers; expose `ETag` for multipart uploads. Treat Markdown as untrusted input. The entry is framework-independent ESM without Workers or WebAssembly, has no import side effects, is safe to import during SSR, and needs Fetch, Web Crypto, Blob/File and AbortController. +**CORS for direct access.** Allow your origin on the API (including `GET /tools/capabilities` and `PUT /files/:id/status/uploaded`), the SSE stream, signed upload URLs and result downloads; allow the `X-Auth-Token`, `X-Auth-UUID`, `Content-Type` and `response-event-stream` headers; expose `ETag` for multipart uploads. Treat Markdown as untrusted input. The entry is framework-independent ESM without Workers or WebAssembly, sends no request and touches no storage when imported, is safe to import during SSR, and needs Fetch, Web Crypto, Blob/File and AbortController. ## Development diff --git a/src/client/index.ts b/src/client/index.ts index c8b8053..2db66f4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,3 +1,4 @@ +import { isValidAuthUuid } from '../cloud/device-id.js'; import { createTransport, type TransportClient } from '../cloud/transport.js'; import type { DeckRuntime } from '../cloud/runtime.js'; import type { DeckTask } from '../cloud/contracts.js'; @@ -28,7 +29,6 @@ const MAX_TIMEOUT_SECONDS = 2_147_483; const MAX_PAGE = 10_000; const CLIENT_OPTIONS = ['apiBase', 'token', 'apiKey', 'spaceId', 'onUnauthorized', 'authUuid']; -const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const OPERATION_OPTIONS = ['spaceId', 'timeout', 'signal', 'onProgress', 'useEventStream', 'pollInterval']; const PARSE_OPTIONS = ['profile', 'password', 'includeImages', 'stayImageAreaRate']; const VERB_OPTIONS: Record = { @@ -63,8 +63,8 @@ export function createClientWith( if (options.onUnauthorized !== undefined && typeof options.onUnauthorized !== 'function') { throw DeckOpsError.usage('onUnauthorized must be a function.'); } - if (options.authUuid !== undefined && !(typeof options.authUuid === 'string' && UUID_V4.test(options.authUuid))) { - throw DeckOpsError.usage('authUuid must be a UUID v4.'); + if (options.authUuid !== undefined && !isValidAuthUuid(options.authUuid)) { + throw DeckOpsError.usage('authUuid must be a device id of 1–100 visible ASCII characters, such as a UUID.'); } const transport: TransportClient = createTransport({ ...(options.authUuid !== undefined ? { authUuid: options.authUuid } : {}), @@ -97,14 +97,7 @@ export function createClientWith( const send = async () => (await transport.files.upload(source.data, { name, spaceId, signal, confirm: await confirmsUploads(signal), onProgress: (progress) => emit({ phase: 'upload', progress }), })).id; - let result: { id: string; reused: boolean }; - try { - result = await uploads.fileId(spaceId, source, send, persist); - } catch (error) { - // Another call's upload this one joined was cancelled: upload it ourselves. - if (signal.aborted || !(error instanceof Error && error.name === 'AbortError')) throw error; - result = await uploads.fileId(spaceId, source, send, persist); - } + const result = await uploads.fileId(spaceId, source, send, { persist, signal }); if (result.reused) emit({ phase: 'upload', progress: 1 }); return result; }; diff --git a/src/client/types.ts b/src/client/types.ts index baaa632..ba99e4d 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -30,8 +30,8 @@ export interface ClientOptions { /** Refresh the same user's token once after a 401; changing accounts/spaces requires a new client. */ onUnauthorized?: () => Promise; /** - * Device id sent as X-Auth-UUID (a UUID v4). Without it the browser reuses localStorage `df_uuid` - * and Node the id saved under ~/.deckflow. + * Device id sent as X-Auth-UUID: 1–100 visible ASCII characters, such as a UUID or a nanoid. + * Without it the browser reuses localStorage `df_uuid` and Node the id saved under ~/.deckflow. */ authUuid?: string; } diff --git a/src/client/uploads.ts b/src/client/uploads.ts index e1d1f32..ed701bb 100644 --- a/src/client/uploads.ts +++ b/src/client/uploads.ts @@ -1,3 +1,4 @@ +import { throwIfAborted, withSignal } from '../cloud/abort.js'; import type { FileIdStore } from '../cloud/runtime.js'; import type { SourceBytes } from './input.js'; @@ -16,23 +17,30 @@ export class UploadCache { /** * The fileId for `source` in `spaceId`; `upload` runs only when nothing matches. `persist: false` - * keeps a sensitive upload (a password-protected source) out of the persistent store. + * keeps a sensitive upload (a password-protected source) out of the persistent store. A call that + * joins another call's upload ends only by its own `signal`: when that upload fails for any reason + * (the other call was cancelled or timed out, say), this call uploads for itself. */ async fileId( - spaceId: string, source: SourceBytes, upload: () => Promise, persist = true, + spaceId: string, source: SourceBytes, upload: () => Promise, options: { persist?: boolean; signal?: AbortSignal } = {}, ): Promise<{ id: string; reused: boolean }> { + const { persist = true, signal } = options; const objects = source.identity ? this.objectsFor(source.identity) : undefined; - const known = objects?.get(spaceId); - if (known) return { id: await known, reused: true }; + for (let known = objects?.get(spaceId); known; known = objects!.get(spaceId)) { + const id = await join(objects!, spaceId, known, signal); + if (id !== undefined) return { id, reused: true }; + } let reused = false; // The object is claimed before hashing, so concurrent calls with it share one upload. const pending = (async () => { const hashKey = await this.hashKey(spaceId, source); - const shared = hashKey === undefined ? undefined : this.byHash.get(hashKey); - if (shared) { - reused = true; - return await shared; + for (let shared = hashKey === undefined ? undefined : this.byHash.get(hashKey); shared; shared = this.byHash.get(hashKey!)) { + const id = await join(this.byHash, hashKey!, shared, signal); + if (id !== undefined) { + reused = true; + return id; + } } const stored = persist && hashKey !== undefined ? await this.stored(hashKey) : undefined; if (stored !== undefined) { @@ -96,6 +104,22 @@ export class UploadCache { } } +/** + * Wait for another call's upload, or until this call is cancelled. Undefined when that upload failed: + * it is dropped so the caller uploads for itself instead of inheriting someone else's failure. + */ +async function join( + map: Map>, key: string, shared: Promise, signal?: AbortSignal, +): Promise { + try { + return await withSignal(shared, signal); + } catch { + throwIfAborted(signal); + if (map.get(key) === shared) map.delete(key); + return undefined; + } +} + /** Hex SHA-256 via Web Crypto; undefined where it is unavailable (insecure browser contexts). */ async function sha256(data: Blob | Uint8Array | ArrayBuffer): Promise { const subtle = globalThis.crypto?.subtle; diff --git a/src/cloud/browser-auth-uuid.ts b/src/cloud/browser-auth-uuid.ts index 305f9b7..02c8f54 100644 --- a/src/cloud/browser-auth-uuid.ts +++ b/src/cloud/browser-auth-uuid.ts @@ -1,11 +1,9 @@ import type { CreateDeckOptions } from './contracts.js'; +import { isValidAuthUuid } from './device-id.js'; -const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; let defaultResolving: Promise | undefined; -export function isValidAuthUuid(value: unknown): value is string { - return typeof value === 'string' && UUID_V4_RE.test(value); -} +export { isValidAuthUuid }; export function generateAuthUuid(): string { if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); diff --git a/src/cloud/contracts.ts b/src/cloud/contracts.ts index 3de5b4c..f951c59 100644 --- a/src/cloud/contracts.ts +++ b/src/cloud/contracts.ts @@ -71,7 +71,7 @@ export interface CreateDeckOptions { apiKey?: (string) | undefined; /** Default space id used by task and upload calls. When omitted, resolved from GET /user. */ spaceId?: (string) | undefined; - /** Explicit client UUID (UUID v4) sent as X-Auth-UUID. Skips automatic persistence. */ + /** Explicit device id (1–100 visible ASCII characters) sent as X-Auth-UUID. Skips automatic persistence. */ authUuid?: (string) | undefined; /** Custom UUID storage for SSR, tests, or embedded integrations. */ authUuidStorage?: (AuthUuidStorage) | undefined; @@ -155,6 +155,11 @@ export interface SubscribeTaskHandlers { onUpdate: (task: DeckTask) => void; /** Called when the stream or parser fails. */ onError?: ((error: Error) => void) | undefined; + /** + * Reconnect after a network failure (default true). Waiting for a task sets it to false: the + * first network failure reaches onError at once, and the wait polls instead. + */ + reconnect?: (boolean) | undefined; } export type UploadPlatform = 'oss' | 'local'; diff --git a/src/cloud/device-id.ts b/src/cloud/device-id.ts new file mode 100644 index 0000000..e01b25b --- /dev/null +++ b/src/cloud/device-id.ts @@ -0,0 +1,11 @@ +/** + * X-Auth-UUID is a device id. The server hashes it into a guest id and stores it in a 100-character + * column; it never parses it. So any 1–100 visible ASCII characters will do — a UUID, or the nanoid an + * app keeps in localStorage `df_uuid` where crypto.randomUUID is missing — and an app's own id is reused, + * never replaced. DeckOps itself generates UUID v4s. + */ +const DEVICE_ID = /^[\x21-\x7e]{1,100}$/; + +export function isValidAuthUuid(value: unknown): value is string { + return typeof value === 'string' && DEVICE_ID.test(value); +} diff --git a/src/cloud/errors.ts b/src/cloud/errors.ts index 2542906..a310f72 100644 --- a/src/cloud/errors.ts +++ b/src/cloud/errors.ts @@ -239,7 +239,8 @@ export class APIError extends Error { static paymentRequired(error: AxiosError): APIError { const data = error.response?.data; const requestId = extractRequestIdFromHeaders(error.response?.headers) ?? extractRequestIdFromBody(data); - const message = 'Payment is required. Please complete checkout and try again.'; + // The server says which package ran out; the generic text is only for a body without a message. + const message = extractErrorMessage(data, 'Payment is required. Please complete checkout and try again.'); return new APIError(formatAPIErrorMessage(402, message, requestId), 402, data as MetadataValue | undefined, requestId); } } diff --git a/src/cloud/http-client.ts b/src/cloud/http-client.ts index d08d9ac..ee17b0c 100644 --- a/src/cloud/http-client.ts +++ b/src/cloud/http-client.ts @@ -11,6 +11,8 @@ type RetriableConfig = Record & { data?: (unknown) | undefined; __deckopsCheckoutRetried?: (boolean) | undefined; __deckopsAuthRetried?: (boolean) | undefined; + /** The token version this request was sent with. */ + __deckopsAuthVersion?: (number) | undefined; signal?: AbortSignal; }; @@ -36,6 +38,8 @@ export class HttpClient { private spaceIdExplicit = false; private resolvedSpaceIdPromise?: (Promise) | undefined; private authRefreshPromise?: (Promise) | undefined; + /** Bumped on every token change, so a 401 for a request sent with an older token is told apart. */ + private authVersion = 0; private guestDowngradePromise?: (Promise) | undefined; /** Set after credentials are cleared due to 401; later parallel 401s still retry as guest. */ private authDowngradedToGuest = false; @@ -65,6 +69,7 @@ export class HttpClient { this.client.interceptors.request.use(async (config) => { const authUuid = await withSignal(this.authUuidPromise, config.signal as AbortSignal | undefined); throwIfAborted(config.signal as AbortSignal | undefined); + (config as unknown as RetriableConfig).__deckopsAuthVersion = this.authVersion; const authHeaders = this.buildAuthHeaders(); const isFormData = typeof FormData !== 'undefined' && config.data instanceof FormData; if (isFormData) { @@ -125,6 +130,10 @@ export class HttpClient { if (status === 401 && cfg && !cfg.__deckopsAuthRetried) { cfg.__deckopsAuthRetried = true; + // The token was refreshed after this request left: retry once with it, without refreshing again. + if (this.token && cfg.__deckopsAuthVersion !== undefined && cfg.__deckopsAuthVersion !== this.authVersion) { + return await this.client.request(cfg); + } const oldSpaceId = this.spaceIdFromConfig(cfg) ?? this.spaceId; if (this.onUnauthorized && this.token && !this.isApiKeyAuth()) { @@ -179,6 +188,7 @@ export class HttpClient { setToken(token: string | undefined): void { this.token = token; + this.authVersion += 1; if (token) { this.authDowngradedToGuest = false; } @@ -313,6 +323,8 @@ export class HttpClient { headers?: (Record) | undefined; params?: (Record) | undefined; signal?: (AbortSignal) | undefined; + /** Retry network failures and gateway errors (default true). */ + retry?: (boolean) | undefined; } = {} ): Promise<{ data: T | ReadableStream | NodeReadableLike; headers: Record }> { if (this.shouldUseFetchStream()) { @@ -350,6 +362,7 @@ export class HttpClient { headers?: (Record) | undefined; params?: (Record) | undefined; signal?: (AbortSignal) | undefined; + retry?: (boolean) | undefined; } ): Promise<{ data: T | ReadableStream; headers: Record }> { let checkoutRetried = false; @@ -364,6 +377,7 @@ export class HttpClient { } let response: Response; + const sentAuthVersion = this.authVersion; try { const headers = { ...this.buildAuthHeaders(), @@ -377,7 +391,7 @@ export class HttpClient { }); } catch (error) { throwIfAborted(config.signal); - if (networkRetry < getRetryDelaysMs().length && this.isRetriableFetchError(error)) { + if (config.retry !== false && networkRetry < getRetryDelaysMs().length && this.isRetriableFetchError(error)) { continue; } throw error; @@ -397,6 +411,11 @@ export class HttpClient { if (response.status === 401 && !authRetried) { authRetried = true; + // The token was refreshed after this request left: retry once with it, without refreshing again. + if (this.token && sentAuthVersion !== this.authVersion) { + networkRetry = 0; + continue; + } const oldSpaceId = params.spaceId ?? this.spaceId; if (this.onUnauthorized && this.token && !this.isApiKeyAuth()) { @@ -426,7 +445,7 @@ export class HttpClient { } if (!response.ok) { - if (networkRetry < getRetryDelaysMs().length && (response.status === 604 || response.status === 502)) { + if (config.retry !== false && networkRetry < getRetryDelaysMs().length && (response.status === 604 || response.status === 502)) { continue; } throw await this.readFetchAPIError(response, responseHeaders); @@ -610,7 +629,8 @@ export class HttpClient { oldSpaceId?: string ): string | undefined { const nextToken = typeof auth === 'string' ? auth : auth.token; - const nextSpaceId = typeof auth === 'string' ? this.spaceId : auth.spaceId; + // A bare token keeps every request in its own space; only an explicit spaceId moves them. + const nextSpaceId = typeof auth === 'string' ? undefined : auth.spaceId; const previousSpaceId = oldSpaceId ?? this.spaceId; this.setToken(nextToken); diff --git a/src/cloud/tasks.ts b/src/cloud/tasks.ts index 5bf3e1f..9a532ae 100644 --- a/src/cloud/tasks.ts +++ b/src/cloud/tasks.ts @@ -362,7 +362,7 @@ export class TasksApi { if (closed || this.isCanceledError(error)) { return; } - if (!this.isSseTransportError(error) || retryCount >= SSE_MAX_RETRIES) { + if (!this.isSseTransportError(error) || handlers.reconnect === false || retryCount >= SSE_MAX_RETRIES) { handlers.onError?.(error as Error); return; } @@ -389,6 +389,7 @@ export class TasksApi { headers: { 'response-event-stream': 'yes' }, signal, params: this.taskQueryParams(handlers.spaceId), + retry: handlers.reconnect !== false, }); const contentType = String(res.headers['content-type'] ?? '').toLowerCase(); @@ -740,6 +741,8 @@ export class TasksApi { void this.subscribe(taskId, { signal, spaceId, + // Any stream failure, with a status or without one (the network), falls back to polling. + reconnect: false, onUpdate: (task) => { onProgress?.(task); if (task.status === 'completed') { diff --git a/src/node/auth-uuid.ts b/src/node/auth-uuid.ts index dcb706f..7363ba4 100644 --- a/src/node/auth-uuid.ts +++ b/src/node/auth-uuid.ts @@ -1,18 +1,15 @@ import type { CreateDeckOptions } from '../cloud/contracts.js'; +import { isValidAuthUuid } from '../cloud/device-id.js'; export const AUTH_UUID_STORAGE_KEY = 'df_uuid'; export const AUTH_UUID_FILENAME = 'auth-uuid'; -const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - type BrowserStorage = { getItem(key: string): string | null; setItem(key: string, value: string): void; }; -export function isValidAuthUuid(value: string | null | undefined): value is string { - return typeof value === 'string' && UUID_V4_RE.test(value); -} +export { isValidAuthUuid }; export function generateAuthUuid(): string { return globalThis.crypto.randomUUID(); diff --git a/tests/browser/client.test.ts b/tests/browser/client.test.ts index f1443a8..c3659c1 100644 --- a/tests/browser/client.test.ts +++ b/tests/browser/client.test.ts @@ -145,6 +145,25 @@ describe('browser SDK over real HTTP', () => { expect(submissions('json-stream')).toHaveLength(1); }); + it('falls back to polling at once when the event stream fails at the network layer', async () => { + const realFetch = globalThis.fetch; + // The browser sends the task event stream through fetch; the network drops only that request. + vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => + new Headers(init?.headers).get('response-event-stream') === 'yes' + ? Promise.reject(new TypeError('Failed to fetch')) + : realFetch(input, init)); + try { + cloud.state('sse-network').forceStatus = 'running'; + setTimeout(() => { delete cloud.state('sse-network').forceStatus; }, 300); + const started = Date.now(); + await expect(clientFor('sse-network').convert(url, { pollInterval: 50 })).resolves.toMatchObject({ content: expect.any(String) }); + expect(Date.now() - started).toBeLessThan(1000); + expect(cloud.state('sse-network').tasks).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); + it('fails closed on 401, with no guest identity request or duplicated task', async () => { await expect(clientFor('unauthorized').convert(url)).rejects.toMatchObject({ name: 'DeckOpsError', code: 'auth_error' }); expect(submissions('unauthorized')).toHaveLength(1); @@ -190,6 +209,8 @@ describe('browser SDK over real HTTP', () => { const error = await clientFor('insufficient').convert(url).catch((caught: unknown) => caught); expect(isDeckOpsError(error, 'quota_error')).toBe(true); expect((error as DeckOpsError).details?.data).toEqual(['Insufficient package balance', { need: 3, remain: 1 }]); + // The server's own words, not a generic checkout message. + expect((error as DeckOpsError).message).toBe('API Error (402): Insufficient package balance'); }); it('reports a failed task with its id and a soft rendering failure as a warning', async () => { @@ -237,6 +258,45 @@ describe('browser SDK over real HTTP', () => { expect(submissions('hanging-upload')).toHaveLength(0); }); + for (const [ending, settings, end] of [ + ['a custom abort reason', {}, (controller: AbortController) => controller.abort(new Error('left the page'))], + ['its own timeout', { timeout: 0.3 }, () => {}], + ] as const) { + it(`lets a call that joined a shared upload finish when the owner ends with ${ending}`, async () => { + const name = `first-upload-hangs_${ending.split(' ').pop()}`; + const client = clientFor(name); + const file = new NodeFile(['%PDF shared'], 'shared.pdf') as unknown as File; + const controller = new AbortController(); + const owner = client.convert(file, { ...settings, signal: controller.signal }).catch((error: unknown) => error); + await vi.waitFor(() => expect(cloud.state(name).requests.some((req) => req.path === '/storage/file-1')).toBe(true)); + const joined = client.extract(file, { to: 'json' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + end(controller); + await expect(joined).resolves.toMatchObject({ content: FIXTURE_DOCUMENT }); + expect(await owner).toSatisfy((error: unknown) => controller.signal.aborted + ? error === controller.signal.reason + : isDeckOpsError(error) && (error.details as { reason?: string }).reason === 'timeout'); + expect(submissions(name).map((task) => (task.body as { fileIds?: string[] }).fileIds)).toEqual([['file-2']]); + }); + } + + it('ends a call waiting on a shared upload by its own signal, leaving the owner uploading', async () => { + const ownerControl = new AbortController(); + const client = clientFor('hanging-upload_joined'); + const file = new NodeFile(['%PDF shared'], 'shared.pdf') as unknown as File; + const owner = client.convert(file, { signal: ownerControl.signal }).catch((error: unknown) => error); + const upload = () => cloud.state('hanging-upload_joined').requests.find((req) => req.path === '/storage/file-1'); + await vi.waitFor(() => expect(upload()).toBeDefined()); + const controller = new AbortController(); + const reason = new Error('closed the dialog'); + const joined = client.extract(file, { to: 'json', signal: controller.signal }).catch((error: unknown) => error); + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.abort(reason); + expect(await joined).toBe(reason); + expect(upload()?.closed).toBeFalsy(); + ownerControl.abort(); await owner; + }); + it('aborts the result download after task completion', async () => { const controller = new AbortController(); const pending = clientFor('hanging-download').convert(url, { signal: controller.signal }); @@ -323,11 +383,32 @@ describe('browser SDK over real HTTP', () => { expect(cloud.state('refresh_concurrent').tasks).toHaveLength(2); }); - it('sends an explicit authUuid as X-Auth-UUID and rejects one that is not a UUID v4', async () => { - const authUuid = '0f8d1f8e-3c2b-4a6f-9e1d-2b3c4d5e6f70'; - await createClient({ apiBase: cloud.apiBase('auth-uuid'), token: 'test-token', spaceId: 'test-space', authUuid }).convert(url); - expect(apiRequests('auth-uuid').every((req) => req.headers['x-auth-uuid'] === authUuid)).toBe(true); - expect(() => createClient({ authUuid: 'device-1' })).toThrow(expect.objectContaining({ code: 'usage_error', message: 'authUuid must be a UUID v4.' })); + it('retries a 401 that arrives after the shared refresh with the new token, without refreshing again', async () => { + const refresh = vi.fn(async () => 'fresh-token'); + const client = createClient({ apiBase: cloud.apiBase('refresh-late'), token: 'stale-token', spaceId: 'test-space', onUnauthorized: refresh }); + await Promise.all([client.convert(url), client.convert({ url: 'https://example.com/b' })]); + expect(refresh).toHaveBeenCalledTimes(1); + expect(submissions('refresh-late').map((req) => req.headers['x-auth-token']).sort()).toEqual(['fresh-token', 'fresh-token', 'stale-token', 'stale-token']); + expect(cloud.state('refresh-late').tasks).toHaveLength(2); + }); + + it('keeps a call-level space when a refresh retries its request', async () => { + const client = createClient({ apiBase: cloud.apiBase('refresh_space'), token: 'stale-token', spaceId: 'space-A', onUnauthorized: async () => 'fresh-token' }); + const result = await client.convert(url, { spaceId: 'space-B' }); + expect(submissions('refresh_space').map((req) => (req.body as { spaceId?: string }).spaceId)).toEqual(['space-B', 'space-B']); + expect(cloud.state('refresh_space').tasks.find((task) => task.id === result.tasks[0]!.id)?.spaceId).toBe('space-B'); + }); + + it('sends an explicit authUuid as X-Auth-UUID, a UUID or a nanoid, and rejects one that cannot be a header', async () => { + for (const [name, authUuid] of [['auth-uuid', '0f8d1f8e-3c2b-4a6f-9e1d-2b3c4d5e6f70'], ['auth-nanoid', 'V1StGXR8_Z5jdHi6B-myT']] as const) { + await createClient({ apiBase: cloud.apiBase(name), token: 'test-token', spaceId: 'test-space', authUuid }).convert(url); + expect(apiRequests(name).every((req) => req.headers['x-auth-uuid'] === authUuid)).toBe(true); + } + for (const authUuid of ['', 'device 1', 'x'.repeat(101), 'gerät']) { + expect(() => createClient({ authUuid })).toThrow(expect.objectContaining({ + code: 'usage_error', message: 'authUuid must be a device id of 1–100 visible ASCII characters, such as a UUID.', + })); + } }); it('rejects with the caller’s own abort reason, or an AbortError DOMException without one', async () => { @@ -345,18 +426,21 @@ describe('browser SDK over real HTTP', () => { }); describe('browser entry without a browser', () => { - it('imports without side effects, works during SSR, and reads the device id from localStorage df_uuid', async () => { - const stored = '5a6b7c8d-1e2f-4a3b-8c4d-9e0f1a2b3c4d'; + it('imports without side effects, works during SSR, and reuses the app\'s own device id from localStorage df_uuid', async () => { + // An app without crypto.randomUUID keeps a nanoid there; it is reused, never replaced. + const stored = 'V1StGXR8_Z5jdHi6B-myT'; const reads: string[] = []; + const writes: string[] = []; const fetchSpy = vi.spyOn(globalThis, 'fetch'); vi.resetModules(); - vi.stubGlobal('localStorage', { getItem: (key: string) => { reads.push(key); return stored; }, setItem: () => {} }); + vi.stubGlobal('localStorage', { getItem: (key: string) => { reads.push(key); return stored; }, setItem: (key: string) => { writes.push(key); } }); try { const entry = await import('../../src/browser/index.js'); expect(reads).toEqual([]); expect(fetchSpy).not.toHaveBeenCalled(); await entry.createClient({ apiBase: cloud.apiBase('df-uuid'), token: 'test-token', spaceId: 'test-space' }).convert(url); expect(reads).toEqual(['df_uuid']); + expect(writes).toEqual([]); expect(apiRequests('df-uuid').every((req) => req.headers['x-auth-uuid'] === stored)).toBe(true); } finally { vi.unstubAllGlobals(); diff --git a/tests/browser/fake-cloud.mjs b/tests/browser/fake-cloud.mjs index 030c8a1..19e0977 100644 --- a/tests/browser/fake-cloud.mjs +++ b/tests/browser/fake-cloud.mjs @@ -127,7 +127,11 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { res.end(kind('corrupt-result') ? Buffer.from('not the reported bytes') : RESULT_BYTES[extension] ?? FIXTURE_PNG); return; } const token = req.headers['x-auth-token']; - if (kind('unauthorized') || (kind('refresh') && token !== 'fresh-token')) { + // refresh-late: every stale request after the first answers 401 only once the first refresh is over. + if (kind('refresh-late') && token !== 'fresh-token' && (state.staleRequests = (state.staleRequests ?? 0) + 1) > 1) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + if (kind('unauthorized') || ((kind('refresh') || kind('refresh-late')) && token !== 'fresh-token')) { json(res, 401, { code: 'auth_error', message: 'Expired browser token' }); return; } if (route === '/user') { json(res, 200, { id: 'fixture-space' }); return; } @@ -157,8 +161,8 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { if (route.startsWith('/storage/')) { // Keep byte counts, not a multi-megabyte string, in test diagnostics. request.body = route.endsWith('/complete') ? bytes.toString() : undefined; - if (kind('hanging-upload')) return; const [, , fileId, part] = route.split('/'); + if (kind('hanging-upload') || (kind('first-upload-hangs') && fileId === 'file-1')) return; const stored = state.uploads.find((upload) => upload.id === fileId); if (stored && (!part || part === 'complete')) stored.stored = true; res.setHeader('ETag', '"fixture-etag"'); diff --git a/tests/cloud/sdk.test.ts b/tests/cloud/sdk.test.ts index 20f37b1..6785c90 100644 --- a/tests/cloud/sdk.test.ts +++ b/tests/cloud/sdk.test.ts @@ -211,7 +211,7 @@ describe('Node cloud transport', () => { cancel(); }); - it('retries event-stream task detail requests after network failures', async () => { + it('polls instead of reconnecting when a wait loses its event stream to the network', async () => { vi.useFakeTimers(); const deck = createDeck({ root: 'http://localhost:3000/api', @@ -221,47 +221,28 @@ describe('Node cloud transport', () => { }); const url = 'http://localhost:3000/api/tools/tasks/task-retry'; let sseAttempts = 0; + let polls = 0; mock.onGet(url).reply((config) => { - if (config.headers?.['response-event-stream'] !== 'yes') { - return [ - 200, - { id: 'task-retry', spaceId: 'space-1', type: 'image.ocr', status: 'running' }, - { 'content-type': 'application/json' }, - ]; - } - - sseAttempts += 1; - if (sseAttempts === 1) { + if (config.headers?.['response-event-stream'] === 'yes') { + sseAttempts += 1; return Promise.reject(new Error('Network Error')); } - + polls += 1; return [ 200, - Readable.from([ - `data: ${JSON.stringify({ - id: 'task-retry', - spaceId: 'space-1', - type: 'image.ocr', - status: 'completed', - })}\n\n`, - ]), - { 'content-type': 'text/event-stream' }, + { id: 'task-retry', spaceId: 'space-1', type: 'image.ocr', status: polls === 1 ? 'running' : 'completed' }, + { 'content-type': 'application/json' }, ]; }); const waiting = deck.tasks.wait('task-retry', { timeout: 30 }); await vi.advanceTimersByTimeAsync(0); - expect(sseAttempts).toBe(1); - - await vi.advanceTimersByTimeAsync(4999); - expect(sseAttempts).toBe(1); - - await vi.advanceTimersByTimeAsync(1); const task = await waiting; expect(task.status).toBe('completed'); - expect(sseAttempts).toBe(2); + expect(sseAttempts).toBe(1); + expect(polls).toBe(2); }); it('stops event-stream network retries after 100 attempts', async () => { @@ -816,7 +797,7 @@ describe('Node cloud transport', () => { expect(task2.id).toBe('task-2'); }); - it('prompts users to complete checkout on 402 without onPaymentRequired', async () => { + it('reports a 402 in the server\'s words, or prompts for checkout when it has none', async () => { const deck = createDeck({ root: 'http://localhost:3000/api', token: 'token-1', @@ -826,16 +807,17 @@ describe('Node cloud transport', () => { mock .onGet('http://localhost:3000/api/tools/tasks/task-1') - .reply(402, { message: 'payment required' }, { 'x-request-id': 'req-pay-1' }); + .reply(402, { message: 'Insufficient package balance' }, { 'x-request-id': 'req-pay-1' }); + mock.onGet('http://localhost:3000/api/tools/tasks/task-2').reply(402, {}); await expect(deck.tasks.get('task-1')).rejects.toSatisfy((error: unknown) => { expect(error).toBeInstanceOf(APIError); const apiError = error as APIError; expect(apiError.statusCode).toBe(402); - expect(apiError.message).toContain('checkout'); - expect(apiError.message).toContain('X-RequestId: req-pay-1'); + expect(apiError.message).toBe('API Error (402): Insufficient package balance [X-RequestId: req-pay-1]'); return true; }); + await expect(deck.tasks.get('task-2')).rejects.toThrow('API Error (402): Payment is required. Please complete checkout and try again.'); }); it('includes X-RequestId in other 4xx errors', async () => { From 5423e89b58f5709193bff40a9315cc81bd1b19fc Mon Sep 17 00:00:00 2001 From: Garfield Date: Tue, 20 Oct 2026 21:17:07 +0800 Subject: [PATCH 2/2] chore(release): 4.0.1 CHANGELOG entry with known limitations, skill tested version, and the browser gzip budget at the measured 39,239 bytes plus 10 %. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++ package.json | 2 +- scripts/check-browser.mjs | 4 ++-- skills/deckops/SKILL.md | 6 +++--- skills/deckops/references/recipes.md | 2 +- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91121db..0b7452a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 4.0.1 — 2026-10-20 — Browser runtime fixes + +Fixes from runtime checks of the 4.0.0 browser entry in platform-slide-editor. + +- A call that shared another call's upload of the same `File` (or the same bytes) no longer inherits + that call's end. When the other call was cancelled with its own reason, timed out or failed, this + call now uploads the file itself instead of failing at once with the other call's error; it ends + only by its own `signal` or timeout, also while it waits for the shared upload. +- Waiting for a task falls back to polling as soon as the event stream fails at the network layer + (`TypeError: Failed to fetch` in browsers, `fetch failed` in Node), as it already did for a failure + with an HTTP status. It used to reconnect every 5 seconds for up to 100 attempts and miss a task that + had long finished. +- Token refresh: a 401 for a request that left before a shared refresh finished is retried once with + the new token instead of calling `onUnauthorized` again. A refresh no longer moves the retried request + to the client's default space: a call with its own `spaceId` keeps it (it used to create the task in + the default space). +- A 402 rejects with the server's message (`API Error (402): Insufficient package balance`) instead of + a generic checkout text; the body stays in `details`. +- `authUuid`, localStorage `df_uuid` and the Node device id accept any 1–100 visible ASCII characters, + such as a nanoid, instead of only a UUID v4. A stored `df_uuid` that is not a UUID is reused instead + of being replaced. An empty value, whitespace or non-ASCII is still a `usage_error`. +- The browser bundle measures 39,239 bytes minified + gzip; CI holds it to that plus 10 %. + +Known limitations: + +- axios registers a `message` listener on `window` when the browser entry is imported in a browser-like + environment. The entry sends no request and reads no storage at import. +- `createClient()` reads, and when missing writes, localStorage `df_uuid` right after it is constructed, + not at the first request. +- An upload sent as one signed PUT reports upload progress only at completion (`progress: 1`); + multipart uploads report each part. + ## 4.0.0 — 2026-10-20 — Probe, render, extract and convert Breaking. DeckOps has four atomic capabilities and no parse verb or IR artifact: the cloud diff --git a/package.json b/package.json index b8620e2..9f24e0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deckflow/deckops", - "version": "4.0.0", + "version": "4.0.1", "description": "Probe, render, extract and convert documents: local DeckProbe reports, and PDF, PPTX, DOCX, Keynote and URLs to images, video, structured JSON and Markdown through DeckOps Cloud.", "type": "module", "main": "./dist/index.js", diff --git a/scripts/check-browser.mjs b/scripts/check-browser.mjs index 3dd7a64..bbe1889 100644 --- a/scripts/check-browser.mjs +++ b/scripts/check-browser.mjs @@ -3,9 +3,9 @@ import fs from 'node:fs/promises'; import { gzipSync } from 'node:zlib'; import { build } from 'esbuild'; -// Measured 39,079 bytes (minified + gzip) at 4.0.0 with render, extract and the route table +// Measured 39,239 bytes (minified + gzip) at 4.0.1 with render, extract and the route table // (3.0.0 was 34,049), plus 10 %. axios stays in the bundle; the probe engine never does. -const GZIP_BUDGET = 42_987; +const GZIP_BUDGET = 43_163; // Bundle the *public package export*, not an internal source path. No aliases, // externals, polyfills, Node types, or browser globals may hide a broken build. diff --git a/skills/deckops/SKILL.md b/skills/deckops/SKILL.md index b44adb1..9fa11e4 100644 --- a/skills/deckops/SKILL.md +++ b/skills/deckops/SKILL.md @@ -15,7 +15,7 @@ metadata: tool: deckops homepage: https://github.com/deckflow/deckops deckops-skill-format: "1" - tested-cli-version: "4.0.0" + tested-cli-version: "4.0.1" --- # DeckOps @@ -42,11 +42,11 @@ deckops --version deckops auth status ``` -The instructions are tested with DeckOps 4.0.0. If `deckops` is missing and Node.js 22.18+ +The instructions are tested with DeckOps 4.0.1. If `deckops` is missing and Node.js 22.18+ and network access are available, use this command prefix in the examples below: ```bash -npx -y @deckflow/deckops@4.0.0 +npx -y @deckflow/deckops@4.0.1 ``` If a cloud verb needs a login and `auth status` reports none, ask the user to run diff --git a/skills/deckops/references/recipes.md b/skills/deckops/references/recipes.md index 8523d3b..2e4b7a6 100644 --- a/skills/deckops/references/recipes.md +++ b/skills/deckops/references/recipes.md @@ -2,7 +2,7 @@ Commands below assume `deckops` is installed and, for the cloud verbs, logged in and that the user agreed to upload the document. With the npx fallback, replace the first word with -`npx -y @deckflow/deckops@4.0.0`. +`npx -y @deckflow/deckops@4.0.1`. Choose output paths inside a task-specific work directory. File names are examples.