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/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/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. 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 () => {