Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions skills/deckops/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/deckops/references/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 4 additions & 11 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Verb, readonly string[]> = {
Expand Down Expand Up @@ -63,8 +63,8 @@ export function createClientWith<Input extends ClientInput = ClientInput>(
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 } : {}),
Expand Down Expand Up @@ -97,14 +97,7 @@ export function createClientWith<Input extends ClientInput = ClientInput>(
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;
};
Expand Down
4 changes: 2 additions & 2 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/**
* 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;
}
Expand Down
40 changes: 32 additions & 8 deletions src/client/uploads.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { throwIfAborted, withSignal } from '../cloud/abort.js';
import type { FileIdStore } from '../cloud/runtime.js';
import type { SourceBytes } from './input.js';

Expand All @@ -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<string>, persist = true,
spaceId: string, source: SourceBytes, upload: () => Promise<string>, 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) {
Expand Down Expand Up @@ -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<string, Promise<string>>, key: string, shared: Promise<string>, signal?: AbortSignal,
): Promise<string | undefined> {
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<string | undefined> {
const subtle = globalThis.crypto?.subtle;
Expand Down
6 changes: 2 additions & 4 deletions src/cloud/browser-auth-uuid.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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();
Expand Down
7 changes: 6 additions & 1 deletion src/cloud/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,6 +155,11 @@ export interface SubscribeTaskHandlers<T extends DeckTaskType = DeckTaskType> {
onUpdate: (task: DeckTask<T>) => 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';
Expand Down
11 changes: 11 additions & 0 deletions src/cloud/device-id.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion src/cloud/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Loading
Loading