@drakkar.software/starfish-client 3.0.0-alpha.28 → 3.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -247,6 +247,36 @@ export declare class StarfishClient {
247
247
  append(path: string, data: Record<string, unknown>, opts?: {
248
248
  ts?: number;
249
249
  }): Promise<PushSuccess>;
250
+ /**
251
+ * Append one element to a **public-write** append-only collection with an
252
+ * Ed25519 author proof but **no cap `Authorization` header**.
253
+ *
254
+ * Unlike {@link append}, which always attaches a cap-signed `Authorization`
255
+ * header from the configured `capProvider`, this method signs only the
256
+ * append-author proof (binding the element to the writer's Ed25519 key) and
257
+ * sends the request without authentication headers. This is required for
258
+ * collections with `writeRoles: ["public"]` — the server's cap-scope check
259
+ * would reject a request carrying a cap whose scope does not cover the path.
260
+ *
261
+ * Typical use-case: writing a sealed invitation to another user's
262
+ * public-write inbox collection without needing a cap scoped to the
263
+ * recipient's namespace. The author proof is optional on the server side
264
+ * (`requireAuthorSignature: false` for a public inbox), but signing anyway
265
+ * binds the stored element to the sender's Ed25519 key for verification in
266
+ * the receive path.
267
+ *
268
+ * The element is sent as `{ data, authorPubkey, authorSignature }`.
269
+ *
270
+ * @param path The push path, e.g. `/push/inbox/{userId}/{shard}`.
271
+ * @param element The JSON element to append.
272
+ * @param signer The sender's Ed25519 keypair (signs the author proof).
273
+ *
274
+ * @throws {AppendHttpError} on a non-2xx response.
275
+ */
276
+ appendAnonymous(path: string, element: Record<string, unknown>, signer: {
277
+ edPubHex: string;
278
+ edPrivHex: string;
279
+ }): Promise<void>;
250
280
  /**
251
281
  * Pull binary data from a blob collection.
252
282
  * Returns raw bytes with the content hash from the ETag header.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Generic SSE live-change transport for Starfish `/events` streams.
3
+ *
4
+ * Exported via `@drakkar.software/starfish-client/events` so apps that don't
5
+ * use live sync can exclude this module from their bundle. The three exports
6
+ * are deliberately domain-free — app-specific parsing is injected via a
7
+ * callback so this module can be used with any Starfish server layout:
8
+ *
9
+ * - {@link parseSseFrames} — WHATWG-compliant incremental SSE frame parser.
10
+ * Pure function, no I/O.
11
+ * - {@link buildSignedEventsUrl} — builds the fetch URL and the stripped
12
+ * signed path, honouring the convention that the signature covers the path
13
+ * AFTER any reverse-proxy mount prefix is removed.
14
+ * - {@link subscribeChanges} — opens an auto-reconnecting SSE subscription with
15
+ * capped exponential backoff. Returns an unsubscribe function.
16
+ *
17
+ * @module starfish-client/events
18
+ */
19
+ /**
20
+ * Incrementally parse SSE frames from a raw text chunk (WHATWG SSE spec §10.1).
21
+ *
22
+ * Call on each `Uint8Array` chunk decoded from the response body stream. Pass
23
+ * the `carry` returned by the previous call as the next call's `carry` argument
24
+ * (start with `""`). When the stream ends, any non-empty `carry` is an
25
+ * incomplete final frame and can be discarded.
26
+ *
27
+ * Only `data:` lines are extracted. `id:`, `event:`, `retry:`, and comment (`:`)
28
+ * lines are intentionally skipped — multi-line `data:` payloads are
29
+ * newline-joined per spec.
30
+ *
31
+ * @param chunk The newly received text (already decoded from UTF-8).
32
+ * @param carry Leftover incomplete-frame text from the previous call.
33
+ * @returns Completed `data` payloads and the new carry for the next call.
34
+ */
35
+ export declare function parseSseFrames(chunk: string, carry: string): {
36
+ events: string[];
37
+ carry: string;
38
+ };
39
+ /**
40
+ * Build the fetch URL and the signed `pathAndQuery` for an SSE request.
41
+ *
42
+ * Two invariants enforced:
43
+ *
44
+ * 1. The `mountBase` prefix (e.g. the `/sync` part of `https://api.example.com/sync`)
45
+ * is stripped from the **signed path** so the signature matches what the origin
46
+ * server verifies after a reverse proxy strips the mount prefix — exactly as
47
+ * `StarfishClient.pull` signs `applyNamespace(path)` without the `baseUrl`
48
+ * origin. Pass `mountBase` as your Starfish `baseUrl`.
49
+ *
50
+ * 2. Query-parameter values are percent-encoded by `URLSearchParams` so a
51
+ * normalising CDN (e.g. Cloudflare) cannot re-encode a literal comma and
52
+ * invalidate the signature.
53
+ *
54
+ * @param eventsUrl Fully-qualified URL of the SSE endpoint
55
+ * (e.g. `"https://api.example.com/sync/events"`).
56
+ * @param params Optional additional query parameters (merged with any
57
+ * existing params on `eventsUrl`).
58
+ * @param mountBase Optional base URL whose pathname is stripped from the
59
+ * signed path (your Starfish `baseUrl`).
60
+ *
61
+ * @returns `{ url, pathAndQuery }` — `url` is for the `fetch` call; `pathAndQuery`
62
+ * is the path that must be signed by `authHeaders`.
63
+ */
64
+ export declare function buildSignedEventsUrl(eventsUrl: string, params?: Record<string, string>, mountBase?: string): {
65
+ url: string;
66
+ pathAndQuery: string;
67
+ };
68
+ /** Options for {@link subscribeChanges}. */
69
+ export interface SubscribeChangesOptions<T> {
70
+ /**
71
+ * Fully-qualified SSE endpoint URL.
72
+ * May be a factory function so a fresh URL (with updated params) is built
73
+ * on every reconnect attempt — useful when query params carry a fresh auth
74
+ * token or updated filter set.
75
+ */
76
+ url: string | (() => string);
77
+ /**
78
+ * The path string that is passed to `authHeaders`. Must be the path the
79
+ * server verifies the signature over — typically the pathname + search AFTER
80
+ * the mount prefix is stripped. If omitted, the full pathname + search of
81
+ * `url` is used as-is.
82
+ *
83
+ * May also be a factory function (called once per connect attempt, in sync
84
+ * with the `url` factory if both are functions).
85
+ */
86
+ pathAndQuery?: string | (() => string);
87
+ /**
88
+ * Async function that returns auth headers for a given HTTP method and
89
+ * `pathAndQuery`. Typically wraps the StarfishClient's `buildAuthHeaders`
90
+ * or equivalent. Called on every reconnect so fresh tokens are obtained
91
+ * after a long disconnect.
92
+ */
93
+ authHeaders: (method: string, pathAndQuery: string) => Promise<Record<string, string>>;
94
+ /**
95
+ * Parse one SSE `data:` payload and return the domain change object, or
96
+ * `null` to skip the frame. This is the ONLY app-specific injection point —
97
+ * all transport logic stays in this module.
98
+ */
99
+ parse: (data: string) => T | null;
100
+ /** Fired for each successfully parsed change event. */
101
+ onChange: (change: T) => void;
102
+ /**
103
+ * Status callback:
104
+ * - `true` when the first byte of a new stream arrives (connected).
105
+ * - `false` when the stream closes, an error is thrown, or unsubscribe is called.
106
+ */
107
+ onStatus?: (connected: boolean) => void;
108
+ /**
109
+ * Minimum reconnect delay in ms. Reset to this value after a successful
110
+ * connect (at least one byte received). Defaults to `1000`.
111
+ */
112
+ minReconnectMs?: number;
113
+ /**
114
+ * Maximum reconnect delay in ms — exponential backoff caps here.
115
+ * Defaults to `30000`.
116
+ */
117
+ maxReconnectMs?: number;
118
+ }
119
+ /**
120
+ * Open a single auto-reconnecting SSE subscription.
121
+ *
122
+ * - Obtains fresh auth headers on every reconnect attempt so long-running
123
+ * sessions survive cap rotation.
124
+ * - Uses capped exponential backoff: resets to `minReconnectMs` after a
125
+ * successful connect, doubles up to `maxReconnectMs` on failure.
126
+ * - Streams are read via the WHATWG `ReadableStream` API (available in all
127
+ * modern environments including React Native / Hermes with polyfill).
128
+ *
129
+ * @returns Unsubscribe function. Call it to abort the stream immediately and
130
+ * stop all reconnect attempts.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * import { buildSignedEventsUrl, subscribeChanges } from "@drakkar.software/starfish-client/events"
135
+ *
136
+ * const unsub = subscribeChanges({
137
+ * url: () => buildSignedEventsUrl(serverUrl + "/events", { ns: namespace }).url,
138
+ * pathAndQuery: () => buildSignedEventsUrl(serverUrl + "/events", { ns: namespace }).pathAndQuery,
139
+ * authHeaders: (method, pq) => client.buildAuthHeadersForPath(method, pq),
140
+ * parse: (data) => {
141
+ * try { return JSON.parse(data) } catch { return null }
142
+ * },
143
+ * onChange: (change) => console.log("change", change),
144
+ * onStatus: (connected) => setConnected(connected),
145
+ * })
146
+ *
147
+ * // Later: unsub()
148
+ * ```
149
+ */
150
+ export declare function subscribeChanges<T>(opts: SubscribeChangesOptions<T>): () => void;
package/dist/events.js ADDED
@@ -0,0 +1,116 @@
1
+ // src/events.ts
2
+ function parseSseFrames(chunk, carry) {
3
+ const text = (carry + chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4
+ const parts = text.split("\n\n");
5
+ const events = [];
6
+ for (let i = 0; i < parts.length - 1; i++) {
7
+ const dataLines = [];
8
+ for (const line of parts[i].split("\n")) {
9
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
10
+ }
11
+ if (dataLines.length > 0) events.push(dataLines.join("\n"));
12
+ }
13
+ return { events, carry: parts[parts.length - 1] };
14
+ }
15
+ function buildSignedEventsUrl(eventsUrl, params, mountBase) {
16
+ const u = new URL(eventsUrl);
17
+ if (params) {
18
+ for (const [k, v] of Object.entries(params)) {
19
+ u.searchParams.set(k, v);
20
+ }
21
+ }
22
+ let basePath = "";
23
+ if (mountBase) {
24
+ try {
25
+ basePath = new URL(mountBase).pathname.replace(/\/+$/, "");
26
+ } catch {
27
+ }
28
+ }
29
+ const signedPath = basePath && u.pathname.startsWith(basePath) ? u.pathname.slice(basePath.length) : u.pathname;
30
+ return { url: u.toString(), pathAndQuery: signedPath + u.search };
31
+ }
32
+ function subscribeChanges(opts) {
33
+ const {
34
+ url: urlOrFactory,
35
+ pathAndQuery: pqOrFactory,
36
+ authHeaders,
37
+ parse,
38
+ onChange,
39
+ onStatus,
40
+ minReconnectMs = 1e3,
41
+ maxReconnectMs = 3e4
42
+ } = opts;
43
+ let closed = false;
44
+ let backoff = minReconnectMs;
45
+ const controller = new AbortController();
46
+ void (async () => {
47
+ while (!closed) {
48
+ const url = typeof urlOrFactory === "function" ? urlOrFactory() : urlOrFactory;
49
+ let pathAndQuery;
50
+ if (typeof pqOrFactory === "function") {
51
+ pathAndQuery = pqOrFactory();
52
+ } else if (pqOrFactory !== void 0) {
53
+ pathAndQuery = pqOrFactory;
54
+ } else {
55
+ try {
56
+ const u = new URL(url);
57
+ pathAndQuery = u.pathname + u.search;
58
+ } catch {
59
+ pathAndQuery = url;
60
+ }
61
+ }
62
+ let extraHeaders;
63
+ try {
64
+ extraHeaders = await authHeaders("GET", pathAndQuery);
65
+ } catch {
66
+ break;
67
+ }
68
+ if (closed) break;
69
+ let connected = false;
70
+ try {
71
+ const res = await fetch(url, {
72
+ headers: { Accept: "text/event-stream", ...extraHeaders },
73
+ signal: controller.signal
74
+ });
75
+ if (!res.ok || !res.body) throw new Error(`SSE ${res.status}`);
76
+ onStatus?.(true);
77
+ connected = true;
78
+ backoff = minReconnectMs;
79
+ const reader = res.body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let carry = "";
82
+ try {
83
+ while (!closed) {
84
+ const { value, done } = await reader.read();
85
+ if (done) break;
86
+ const chunk = decoder.decode(value, { stream: true });
87
+ const { events, carry: next } = parseSseFrames(chunk, carry);
88
+ carry = next;
89
+ for (const data of events) {
90
+ const change = parse(data);
91
+ if (change !== null) onChange(change);
92
+ }
93
+ }
94
+ } finally {
95
+ reader.releaseLock();
96
+ }
97
+ } catch {
98
+ }
99
+ if (closed || controller.signal.aborted) break;
100
+ if (connected) onStatus?.(false);
101
+ await new Promise((resolve) => setTimeout(resolve, backoff));
102
+ if (!connected) backoff = Math.min(backoff * 2, maxReconnectMs);
103
+ }
104
+ })();
105
+ return () => {
106
+ closed = true;
107
+ controller.abort();
108
+ onStatus?.(false);
109
+ };
110
+ }
111
+ export {
112
+ buildSignedEventsUrl,
113
+ parseSseFrames,
114
+ subscribeChanges
115
+ };
116
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/events.ts"],
4
+ "sourcesContent": ["/**\n * Generic SSE live-change transport for Starfish `/events` streams.\n *\n * Exported via `@drakkar.software/starfish-client/events` so apps that don't\n * use live sync can exclude this module from their bundle. The three exports\n * are deliberately domain-free \u2014 app-specific parsing is injected via a\n * callback so this module can be used with any Starfish server layout:\n *\n * - {@link parseSseFrames} \u2014 WHATWG-compliant incremental SSE frame parser.\n * Pure function, no I/O.\n * - {@link buildSignedEventsUrl} \u2014 builds the fetch URL and the stripped\n * signed path, honouring the convention that the signature covers the path\n * AFTER any reverse-proxy mount prefix is removed.\n * - {@link subscribeChanges} \u2014 opens an auto-reconnecting SSE subscription with\n * capped exponential backoff. Returns an unsubscribe function.\n *\n * @module starfish-client/events\n */\n\n// \u2500\u2500 parseSseFrames \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Incrementally parse SSE frames from a raw text chunk (WHATWG SSE spec \u00A710.1).\n *\n * Call on each `Uint8Array` chunk decoded from the response body stream. Pass\n * the `carry` returned by the previous call as the next call's `carry` argument\n * (start with `\"\"`). When the stream ends, any non-empty `carry` is an\n * incomplete final frame and can be discarded.\n *\n * Only `data:` lines are extracted. `id:`, `event:`, `retry:`, and comment (`:`)\n * lines are intentionally skipped \u2014 multi-line `data:` payloads are\n * newline-joined per spec.\n *\n * @param chunk The newly received text (already decoded from UTF-8).\n * @param carry Leftover incomplete-frame text from the previous call.\n * @returns Completed `data` payloads and the new carry for the next call.\n */\nexport function parseSseFrames(\n chunk: string,\n carry: string,\n): { events: string[]; carry: string } {\n // Normalise \\r\\n and \\r \u2192 \\n per spec.\n const text = (carry + chunk).replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\")\n // Frames are separated by blank lines.\n const parts = text.split(\"\\n\\n\")\n const events: string[] = []\n for (let i = 0; i < parts.length - 1; i++) {\n const dataLines: string[] = []\n for (const line of parts[i]!.split(\"\\n\")) {\n if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).trimStart())\n // id:, event:, retry:, and comment (:) lines are intentionally ignored.\n }\n if (dataLines.length > 0) events.push(dataLines.join(\"\\n\"))\n }\n // The last part may be an incomplete frame \u2014 carry it to the next call.\n return { events, carry: parts[parts.length - 1]! }\n}\n\n// \u2500\u2500 buildSignedEventsUrl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Build the fetch URL and the signed `pathAndQuery` for an SSE request.\n *\n * Two invariants enforced:\n *\n * 1. The `mountBase` prefix (e.g. the `/sync` part of `https://api.example.com/sync`)\n * is stripped from the **signed path** so the signature matches what the origin\n * server verifies after a reverse proxy strips the mount prefix \u2014 exactly as\n * `StarfishClient.pull` signs `applyNamespace(path)` without the `baseUrl`\n * origin. Pass `mountBase` as your Starfish `baseUrl`.\n *\n * 2. Query-parameter values are percent-encoded by `URLSearchParams` so a\n * normalising CDN (e.g. Cloudflare) cannot re-encode a literal comma and\n * invalidate the signature.\n *\n * @param eventsUrl Fully-qualified URL of the SSE endpoint\n * (e.g. `\"https://api.example.com/sync/events\"`).\n * @param params Optional additional query parameters (merged with any\n * existing params on `eventsUrl`).\n * @param mountBase Optional base URL whose pathname is stripped from the\n * signed path (your Starfish `baseUrl`).\n *\n * @returns `{ url, pathAndQuery }` \u2014 `url` is for the `fetch` call; `pathAndQuery`\n * is the path that must be signed by `authHeaders`.\n */\nexport function buildSignedEventsUrl(\n eventsUrl: string,\n params?: Record<string, string>,\n mountBase?: string,\n): { url: string; pathAndQuery: string } {\n const u = new URL(eventsUrl)\n\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n u.searchParams.set(k, v)\n }\n }\n\n let basePath = \"\"\n if (mountBase) {\n try {\n basePath = new URL(mountBase).pathname.replace(/\\/+$/, \"\")\n } catch {\n // Relative base \u2014 no prefix to strip.\n }\n }\n const signedPath =\n basePath && u.pathname.startsWith(basePath)\n ? u.pathname.slice(basePath.length)\n : u.pathname\n\n return { url: u.toString(), pathAndQuery: signedPath + u.search }\n}\n\n// \u2500\u2500 subscribeChanges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Options for {@link subscribeChanges}. */\nexport interface SubscribeChangesOptions<T> {\n /**\n * Fully-qualified SSE endpoint URL.\n * May be a factory function so a fresh URL (with updated params) is built\n * on every reconnect attempt \u2014 useful when query params carry a fresh auth\n * token or updated filter set.\n */\n url: string | (() => string)\n /**\n * The path string that is passed to `authHeaders`. Must be the path the\n * server verifies the signature over \u2014 typically the pathname + search AFTER\n * the mount prefix is stripped. If omitted, the full pathname + search of\n * `url` is used as-is.\n *\n * May also be a factory function (called once per connect attempt, in sync\n * with the `url` factory if both are functions).\n */\n pathAndQuery?: string | (() => string)\n /**\n * Async function that returns auth headers for a given HTTP method and\n * `pathAndQuery`. Typically wraps the StarfishClient's `buildAuthHeaders`\n * or equivalent. Called on every reconnect so fresh tokens are obtained\n * after a long disconnect.\n */\n authHeaders: (method: string, pathAndQuery: string) => Promise<Record<string, string>>\n /**\n * Parse one SSE `data:` payload and return the domain change object, or\n * `null` to skip the frame. This is the ONLY app-specific injection point \u2014\n * all transport logic stays in this module.\n */\n parse: (data: string) => T | null\n /** Fired for each successfully parsed change event. */\n onChange: (change: T) => void\n /**\n * Status callback:\n * - `true` when the first byte of a new stream arrives (connected).\n * - `false` when the stream closes, an error is thrown, or unsubscribe is called.\n */\n onStatus?: (connected: boolean) => void\n /**\n * Minimum reconnect delay in ms. Reset to this value after a successful\n * connect (at least one byte received). Defaults to `1000`.\n */\n minReconnectMs?: number\n /**\n * Maximum reconnect delay in ms \u2014 exponential backoff caps here.\n * Defaults to `30000`.\n */\n maxReconnectMs?: number\n}\n\n/**\n * Open a single auto-reconnecting SSE subscription.\n *\n * - Obtains fresh auth headers on every reconnect attempt so long-running\n * sessions survive cap rotation.\n * - Uses capped exponential backoff: resets to `minReconnectMs` after a\n * successful connect, doubles up to `maxReconnectMs` on failure.\n * - Streams are read via the WHATWG `ReadableStream` API (available in all\n * modern environments including React Native / Hermes with polyfill).\n *\n * @returns Unsubscribe function. Call it to abort the stream immediately and\n * stop all reconnect attempts.\n *\n * @example\n * ```ts\n * import { buildSignedEventsUrl, subscribeChanges } from \"@drakkar.software/starfish-client/events\"\n *\n * const unsub = subscribeChanges({\n * url: () => buildSignedEventsUrl(serverUrl + \"/events\", { ns: namespace }).url,\n * pathAndQuery: () => buildSignedEventsUrl(serverUrl + \"/events\", { ns: namespace }).pathAndQuery,\n * authHeaders: (method, pq) => client.buildAuthHeadersForPath(method, pq),\n * parse: (data) => {\n * try { return JSON.parse(data) } catch { return null }\n * },\n * onChange: (change) => console.log(\"change\", change),\n * onStatus: (connected) => setConnected(connected),\n * })\n *\n * // Later: unsub()\n * ```\n */\nexport function subscribeChanges<T>(opts: SubscribeChangesOptions<T>): () => void {\n const {\n url: urlOrFactory,\n pathAndQuery: pqOrFactory,\n authHeaders,\n parse,\n onChange,\n onStatus,\n minReconnectMs = 1_000,\n maxReconnectMs = 30_000,\n } = opts\n\n let closed = false\n let backoff = minReconnectMs\n const controller = new AbortController()\n\n void (async () => {\n while (!closed) {\n const url = typeof urlOrFactory === \"function\" ? urlOrFactory() : urlOrFactory\n\n let pathAndQuery: string\n if (typeof pqOrFactory === \"function\") {\n pathAndQuery = pqOrFactory()\n } else if (pqOrFactory !== undefined) {\n pathAndQuery = pqOrFactory\n } else {\n // Default: pathname + search from the resolved URL.\n try {\n const u = new URL(url)\n pathAndQuery = u.pathname + u.search\n } catch {\n pathAndQuery = url\n }\n }\n\n let extraHeaders: Record<string, string>\n try {\n extraHeaders = await authHeaders(\"GET\", pathAndQuery)\n } catch {\n // Signing failure \u2014 session likely gone. Stop reconnecting.\n break\n }\n if (closed) break\n\n let connected = false\n try {\n const res = await fetch(url, {\n headers: { Accept: \"text/event-stream\", ...extraHeaders },\n signal: controller.signal,\n })\n if (!res.ok || !res.body) throw new Error(`SSE ${res.status}`)\n\n onStatus?.(true)\n connected = true\n backoff = minReconnectMs // Reset backoff on successful connect.\n\n const reader = (res.body as ReadableStream<Uint8Array>).getReader()\n const decoder = new TextDecoder()\n let carry = \"\"\n\n try {\n while (!closed) {\n const { value, done } = await reader.read()\n if (done) break\n const chunk = decoder.decode(value, { stream: true })\n const { events, carry: next } = parseSseFrames(chunk, carry)\n carry = next\n for (const data of events) {\n const change = parse(data)\n if (change !== null) onChange(change)\n }\n }\n } finally {\n reader.releaseLock()\n }\n } catch {\n // Network error, abort, or non-ok response \u2014 fall through to reconnect.\n }\n\n if (closed || controller.signal.aborted) break\n\n if (connected) onStatus?.(false)\n\n // Backoff before the next attempt (doubles on each failure, capped at max).\n await new Promise<void>((resolve) => setTimeout(resolve, backoff))\n if (!connected) backoff = Math.min(backoff * 2, maxReconnectMs)\n }\n })()\n\n return () => {\n closed = true\n controller.abort()\n onStatus?.(false)\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/fetch.d.ts CHANGED
@@ -55,6 +55,33 @@ export declare class CircuitBreaker {
55
55
  * are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).
56
56
  */
57
57
  export declare function createCompressedFetch(inner?: typeof globalThis.fetch): typeof globalThis.fetch;
58
+ /**
59
+ * Wrap `fetch` to bound the **connect / Time-to-First-Byte** phase with a
60
+ * timeout. The timer is cleared as soon as the response HEADERS arrive (i.e.
61
+ * the `fetch()` promise resolves), so a slow large-body download after a fast
62
+ * connection is not interrupted. Only the initial "will the server even
63
+ * respond?" window is bounded.
64
+ *
65
+ * The wrapper composes with the caller's `AbortSignal`: if the caller's signal
66
+ * fires first the request is still aborted and the timeout timer is cleaned up.
67
+ *
68
+ * @param timeoutMs How long (in ms) to wait for the server to start
69
+ * responding before aborting. Default `10 000`.
70
+ * @param inner Optional underlying `fetch` to wrap (defaults to
71
+ * `globalThis.fetch`).
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * import { createTimeoutFetch, createResilientFetch } from "@drakkar.software/starfish-client/fetch"
76
+ *
77
+ * const { fetch: resilient } = createResilientFetch()
78
+ * const client = new StarfishClient({
79
+ * baseUrl: "https://api.example.com",
80
+ * fetch: createTimeoutFetch(8_000, resilient),
81
+ * })
82
+ * ```
83
+ */
84
+ export declare function createTimeoutFetch(timeoutMs?: number, inner?: typeof globalThis.fetch): typeof globalThis.fetch;
58
85
  /**
59
86
  * Combines retry and circuit breaker into a single resilient fetch wrapper.
60
87
  * Rejects immediately when the circuit is open.
package/dist/fetch.js CHANGED
@@ -112,6 +112,37 @@ function createCompressedFetch(inner) {
112
112
  }
113
113
  };
114
114
  }
115
+ function createTimeoutFetch(timeoutMs = 1e4, inner) {
116
+ const baseFetch = inner ?? globalThis.fetch.bind(globalThis);
117
+ return async (input, init) => {
118
+ const timeoutCtrl = new AbortController();
119
+ const timer = setTimeout(() => timeoutCtrl.abort(new Error(`connect timeout after ${timeoutMs}ms`)), timeoutMs);
120
+ const callerSignal = init?.signal;
121
+ let combinedSignal;
122
+ if (callerSignal) {
123
+ if (typeof AbortSignal.any === "function") {
124
+ combinedSignal = AbortSignal.any([timeoutCtrl.signal, callerSignal]);
125
+ } else {
126
+ const combo = new AbortController();
127
+ const onCallerAbort = () => combo.abort(callerSignal.reason);
128
+ const onTimeout = () => combo.abort(timeoutCtrl.signal.reason);
129
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
130
+ timeoutCtrl.signal.addEventListener("abort", onTimeout, { once: true });
131
+ combinedSignal = combo.signal;
132
+ }
133
+ } else {
134
+ combinedSignal = timeoutCtrl.signal;
135
+ }
136
+ try {
137
+ const res = await baseFetch(input, { ...init, signal: combinedSignal });
138
+ clearTimeout(timer);
139
+ return res;
140
+ } catch (err) {
141
+ clearTimeout(timer);
142
+ throw err;
143
+ }
144
+ };
145
+ }
115
146
  function createResilientFetch(retryOptions, breakerOptions) {
116
147
  const breaker = new CircuitBreaker(breakerOptions);
117
148
  const retryFetch = createRetryFetch(retryOptions);
@@ -141,6 +172,7 @@ export {
141
172
  createCompressedFetch,
142
173
  createResilientFetch,
143
174
  createRetryFetch,
175
+ createTimeoutFetch,
144
176
  parseRetryAfterMs
145
177
  };
146
178
  //# sourceMappingURL=fetch.js.map
package/dist/fetch.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/fetch.ts"],
4
- "sourcesContent": ["/**\n * Parse a `Retry-After` header value into milliseconds.\n *\n * - Numeric string (`\"30\"`) \u2014 treated as seconds \u00D7 1000.\n * - HTTP-date string \u2014 delta from now in ms (floored to 0).\n * - `null`, empty, or unparseable \u2014 returns `opts.fallbackMs`.\n *\n * All results are clamped to `[0, opts.maxMs]`.\n */\nexport function parseRetryAfterMs(\n header: string | null | undefined,\n opts: { fallbackMs: number; maxMs: number },\n): number {\n const { fallbackMs, maxMs } = opts\n const trimmed = header?.trim()\n if (trimmed) {\n const seconds = Number(trimmed)\n if (!isNaN(seconds)) return Math.min(seconds * 1000, maxMs)\n const date = Date.parse(trimmed)\n if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs)\n }\n return Math.min(fallbackMs, maxMs)\n}\n\n/** Error category returned by classifyError. */\nexport type ErrorCategory =\n | \"network\"\n | \"auth\"\n | \"conflict\"\n | \"rate-limited\"\n | \"server\"\n | \"client\"\n | \"unknown\"\n\n/** Classify an error from a fetch response or network failure. */\nexport function classifyError(err: unknown): ErrorCategory {\n if (err instanceof Response || (err && typeof err === \"object\" && \"status\" in err)) {\n const status = (err as { status: unknown }).status\n if (typeof status !== \"number\" || isNaN(status)) return \"unknown\"\n if (status === 0) return \"network\"\n if (status === 401 || status === 403) return \"auth\"\n if (status === 409) return \"conflict\"\n if (status === 429) return \"rate-limited\"\n if (status >= 500) return \"server\"\n if (status >= 400) return \"client\"\n }\n if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return \"network\"\n return \"unknown\"\n}\n\nexport interface RetryOptions {\n /** Max number of retries (default: 3). */\n maxRetries?: number\n /** Initial delay in ms before first retry (default: 500). */\n initialDelayMs?: number\n /** Maximum delay in ms (default: 10000). */\n maxDelayMs?: number\n}\n\n/**\n * Wraps a fetch function with automatic retry for retriable errors\n * (network failures, 429, 5xx). Respects Retry-After headers.\n */\nexport function createRetryFetch(options?: RetryOptions): typeof globalThis.fetch {\n const maxRetries = Math.max(0, options?.maxRetries ?? 3)\n const initialDelay = options?.initialDelayMs ?? 500\n const maxDelay = options?.maxDelayMs ?? 10_000\n\n return async (input, init?) => {\n let attempt = 0\n while (true) {\n try {\n const res = await globalThis.fetch(input, init)\n if (res.ok || attempt >= maxRetries) return res\n\n const category = classifyError(res)\n if (category !== \"rate-limited\" && category !== \"server\") return res\n\n const retryAfterHeader = res.headers.get(\"Retry-After\")\n const exponentialDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n // When the header is present but unparseable, original falls back to\n // initialDelay (not exponential). Preserve that by checking presence first.\n const delay = parseRetryAfterMs(retryAfterHeader, {\n fallbackMs: retryAfterHeader?.trim() ? initialDelay : exponentialDelay,\n maxMs: maxDelay,\n })\n\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n } catch (err) {\n if (attempt >= maxRetries) throw err\n const category = classifyError(err)\n if (category !== \"network\") throw err\n\n const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n }\n }\n }\n}\n\ntype BreakerState = \"closed\" | \"open\" | \"half-open\"\n\nexport interface CircuitBreakerOptions {\n /** Number of consecutive failures to open the circuit (default: 5). */\n threshold?: number\n /** Cooldown in ms before transitioning from open to half-open (default: 30000). */\n cooldownMs?: number\n}\n\n/** Circuit breaker that prevents requests when the backend is unavailable. */\nexport class CircuitBreaker {\n private state: BreakerState = \"closed\"\n private failures = 0\n private openedAt = 0\n private readonly threshold: number\n private readonly cooldownMs: number\n\n constructor(options?: CircuitBreakerOptions) {\n this.threshold = options?.threshold ?? 5\n this.cooldownMs = options?.cooldownMs ?? 30_000\n }\n\n getState(): BreakerState {\n this.maybeTransition()\n return this.state\n }\n\n isOpen(): boolean {\n return this.getState() === \"open\"\n }\n\n recordSuccess(): void {\n this.failures = 0\n this.state = \"closed\"\n }\n\n recordFailure(): void {\n this.failures++\n if (this.state === \"half-open\" || this.failures >= this.threshold) {\n this.state = \"open\"\n this.openedAt = Date.now()\n }\n }\n\n private maybeTransition(): void {\n if (this.state === \"open\" && Date.now() - this.openedAt >= this.cooldownMs) {\n this.state = \"half-open\"\n }\n }\n}\n\n/**\n * Wraps fetch to gzip-compress string request bodies using the CompressionStream API.\n * Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)\n * are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).\n */\nexport function createCompressedFetch(inner?: typeof globalThis.fetch): typeof globalThis.fetch {\n const baseFetch = inner ?? globalThis.fetch.bind(globalThis)\n return async (input, init?) => {\n if (!init?.body || typeof CompressionStream === \"undefined\") {\n return baseFetch(input, init)\n }\n\n const bodyText = typeof init.body === \"string\" ? init.body : null\n if (!bodyText) return baseFetch(input, init)\n\n try {\n const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream(\"gzip\"))\n const compressed = await new Response(stream).arrayBuffer()\n\n const normalized = Object.fromEntries(new Headers(init.headers as HeadersInit).entries())\n normalized[\"content-encoding\"] = \"gzip\"\n\n return baseFetch(input, {\n ...init,\n body: compressed,\n headers: normalized,\n })\n } catch {\n return baseFetch(input, init)\n }\n }\n}\n\n/**\n * Combines retry and circuit breaker into a single resilient fetch wrapper.\n * Rejects immediately when the circuit is open.\n */\nexport function createResilientFetch(\n retryOptions?: RetryOptions,\n breakerOptions?: CircuitBreakerOptions,\n): { fetch: typeof globalThis.fetch; breaker: CircuitBreaker } {\n const breaker = new CircuitBreaker(breakerOptions)\n const retryFetch = createRetryFetch(retryOptions)\n\n const resilientFetch: typeof globalThis.fetch = async (input, init?) => {\n if (breaker.isOpen()) {\n const cooldown = Math.ceil((breakerOptions?.cooldownMs ?? 30_000) / 1000)\n throw new Error(`Request blocked: too many consecutive failures. Retry in ${cooldown}s.`)\n }\n\n try {\n const res = await retryFetch(input, init)\n if (res.status >= 500) {\n breaker.recordFailure()\n } else {\n breaker.recordSuccess()\n }\n return res\n } catch (err) {\n breaker.recordFailure()\n throw err\n }\n }\n\n return { fetch: resilientFetch, breaker }\n}\n"],
5
- "mappings": ";AASO,SAAS,kBACd,QACA,MACQ;AACR,QAAM,EAAE,YAAY,MAAM,IAAI;AAC9B,QAAM,UAAU,QAAQ,KAAK;AAC7B,MAAI,SAAS;AACX,UAAM,UAAU,OAAO,OAAO;AAC9B,QAAI,CAAC,MAAM,OAAO,EAAG,QAAO,KAAK,IAAI,UAAU,KAAM,KAAK;AAC1D,UAAM,OAAO,KAAK,MAAM,OAAO;AAC/B,QAAI,CAAC,MAAM,IAAI,EAAG,QAAO,KAAK,IAAI,KAAK,IAAI,OAAO,KAAK,IAAI,GAAG,CAAC,GAAG,KAAK;AAAA,EACzE;AACA,SAAO,KAAK,IAAI,YAAY,KAAK;AACnC;AAaO,SAAS,cAAc,KAA6B;AACzD,MAAI,eAAe,YAAa,OAAO,OAAO,QAAQ,YAAY,YAAY,KAAM;AAClF,UAAM,SAAU,IAA4B;AAC5C,QAAI,OAAO,WAAW,YAAY,MAAM,MAAM,EAAG,QAAO;AACxD,QAAI,WAAW,EAAG,QAAO;AACzB,QAAI,WAAW,OAAO,WAAW,IAAK,QAAO;AAC7C,QAAI,WAAW,IAAK,QAAO;AAC3B,QAAI,WAAW,IAAK,QAAO;AAC3B,QAAI,UAAU,IAAK,QAAO;AAC1B,QAAI,UAAU,IAAK,QAAO;AAAA,EAC5B;AACA,MAAI,eAAe,SAAS,2EAA2E,KAAK,IAAI,OAAO,EAAG,QAAO;AACjI,SAAO;AACT;AAeO,SAAS,iBAAiB,SAAiD;AAChF,QAAM,aAAa,KAAK,IAAI,GAAG,SAAS,cAAc,CAAC;AACvD,QAAM,eAAe,SAAS,kBAAkB;AAChD,QAAM,WAAW,SAAS,cAAc;AAExC,SAAO,OAAO,OAAO,SAAU;AAC7B,QAAI,UAAU;AACd,WAAO,MAAM;AACX,UAAI;AACF,cAAM,MAAM,MAAM,WAAW,MAAM,OAAO,IAAI;AAC9C,YAAI,IAAI,MAAM,WAAW,WAAY,QAAO;AAE5C,cAAM,WAAW,cAAc,GAAG;AAClC,YAAI,aAAa,kBAAkB,aAAa,SAAU,QAAO;AAEjE,cAAM,mBAAmB,IAAI,QAAQ,IAAI,aAAa;AACtD,cAAM,mBAAmB,KAAK,IAAI,eAAe,KAAK,IAAI,GAAG,OAAO,GAAG,QAAQ;AAG/E,cAAM,QAAQ,kBAAkB,kBAAkB;AAAA,UAChD,YAAY,kBAAkB,KAAK,IAAI,eAAe;AAAA,UACtD,OAAO;AAAA,QACT,CAAC;AAED,cAAM,IAAI,QAAc,CAAC,MAAM,WAAW,GAAG,KAAK,CAAC;AACnD;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,WAAW,WAAY,OAAM;AACjC,cAAM,WAAW,cAAc,GAAG;AAClC,YAAI,aAAa,UAAW,OAAM;AAElC,cAAM,QAAQ,KAAK,IAAI,eAAe,KAAK,IAAI,GAAG,OAAO,GAAG,QAAQ;AACpE,cAAM,IAAI,QAAc,CAAC,MAAM,WAAW,GAAG,KAAK,CAAC;AACnD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAYO,IAAM,iBAAN,MAAqB;AAAA,EAClB,QAAsB;AAAA,EACtB,WAAW;AAAA,EACX,WAAW;AAAA,EACF;AAAA,EACA;AAAA,EAEjB,YAAY,SAAiC;AAC3C,SAAK,YAAY,SAAS,aAAa;AACvC,SAAK,aAAa,SAAS,cAAc;AAAA,EAC3C;AAAA,EAEA,WAAyB;AACvB,SAAK,gBAAgB;AACrB,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,SAAkB;AAChB,WAAO,KAAK,SAAS,MAAM;AAAA,EAC7B;AAAA,EAEA,gBAAsB;AACpB,SAAK,WAAW;AAChB,SAAK,QAAQ;AAAA,EACf;AAAA,EAEA,gBAAsB;AACpB,SAAK;AACL,QAAI,KAAK,UAAU,eAAe,KAAK,YAAY,KAAK,WAAW;AACjE,WAAK,QAAQ;AACb,WAAK,WAAW,KAAK,IAAI;AAAA,IAC3B;AAAA,EACF;AAAA,EAEQ,kBAAwB;AAC9B,QAAI,KAAK,UAAU,UAAU,KAAK,IAAI,IAAI,KAAK,YAAY,KAAK,YAAY;AAC1E,WAAK,QAAQ;AAAA,IACf;AAAA,EACF;AACF;AAOO,SAAS,sBAAsB,OAA0D;AAC9F,QAAM,YAAY,SAAS,WAAW,MAAM,KAAK,UAAU;AAC3D,SAAO,OAAO,OAAO,SAAU;AAC7B,QAAI,CAAC,MAAM,QAAQ,OAAO,sBAAsB,aAAa;AAC3D,aAAO,UAAU,OAAO,IAAI;AAAA,IAC9B;AAEA,UAAM,WAAW,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO;AAC7D,QAAI,CAAC,SAAU,QAAO,UAAU,OAAO,IAAI;AAE3C,QAAI;AACF,YAAM,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,YAAY,IAAI,kBAAkB,MAAM,CAAC;AACtF,YAAM,aAAa,MAAM,IAAI,SAAS,MAAM,EAAE,YAAY;AAE1D,YAAM,aAAa,OAAO,YAAY,IAAI,QAAQ,KAAK,OAAsB,EAAE,QAAQ,CAAC;AACxF,iBAAW,kBAAkB,IAAI;AAEjC,aAAO,UAAU,OAAO;AAAA,QACtB,GAAG;AAAA,QACH,MAAM;AAAA,QACN,SAAS;AAAA,MACX,CAAC;AAAA,IACH,QAAQ;AACN,aAAO,UAAU,OAAO,IAAI;AAAA,IAC9B;AAAA,EACF;AACF;AAMO,SAAS,qBACd,cACA,gBAC6D;AAC7D,QAAM,UAAU,IAAI,eAAe,cAAc;AACjD,QAAM,aAAa,iBAAiB,YAAY;AAEhD,QAAM,iBAA0C,OAAO,OAAO,SAAU;AACtE,QAAI,QAAQ,OAAO,GAAG;AACpB,YAAM,WAAW,KAAK,MAAM,gBAAgB,cAAc,OAAU,GAAI;AACxE,YAAM,IAAI,MAAM,4DAA4D,QAAQ,IAAI;AAAA,IAC1F;AAEA,QAAI;AACF,YAAM,MAAM,MAAM,WAAW,OAAO,IAAI;AACxC,UAAI,IAAI,UAAU,KAAK;AACrB,gBAAQ,cAAc;AAAA,MACxB,OAAO;AACL,gBAAQ,cAAc;AAAA,MACxB;AACA,aAAO;AAAA,IACT,SAAS,KAAK;AACZ,cAAQ,cAAc;AACtB,YAAM;AAAA,IACR;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,gBAAgB,QAAQ;AAC1C;",
4
+ "sourcesContent": ["/**\n * Parse a `Retry-After` header value into milliseconds.\n *\n * - Numeric string (`\"30\"`) \u2014 treated as seconds \u00D7 1000.\n * - HTTP-date string \u2014 delta from now in ms (floored to 0).\n * - `null`, empty, or unparseable \u2014 returns `opts.fallbackMs`.\n *\n * All results are clamped to `[0, opts.maxMs]`.\n */\nexport function parseRetryAfterMs(\n header: string | null | undefined,\n opts: { fallbackMs: number; maxMs: number },\n): number {\n const { fallbackMs, maxMs } = opts\n const trimmed = header?.trim()\n if (trimmed) {\n const seconds = Number(trimmed)\n if (!isNaN(seconds)) return Math.min(seconds * 1000, maxMs)\n const date = Date.parse(trimmed)\n if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs)\n }\n return Math.min(fallbackMs, maxMs)\n}\n\n/** Error category returned by classifyError. */\nexport type ErrorCategory =\n | \"network\"\n | \"auth\"\n | \"conflict\"\n | \"rate-limited\"\n | \"server\"\n | \"client\"\n | \"unknown\"\n\n/** Classify an error from a fetch response or network failure. */\nexport function classifyError(err: unknown): ErrorCategory {\n if (err instanceof Response || (err && typeof err === \"object\" && \"status\" in err)) {\n const status = (err as { status: unknown }).status\n if (typeof status !== \"number\" || isNaN(status)) return \"unknown\"\n if (status === 0) return \"network\"\n if (status === 401 || status === 403) return \"auth\"\n if (status === 409) return \"conflict\"\n if (status === 429) return \"rate-limited\"\n if (status >= 500) return \"server\"\n if (status >= 400) return \"client\"\n }\n if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return \"network\"\n return \"unknown\"\n}\n\nexport interface RetryOptions {\n /** Max number of retries (default: 3). */\n maxRetries?: number\n /** Initial delay in ms before first retry (default: 500). */\n initialDelayMs?: number\n /** Maximum delay in ms (default: 10000). */\n maxDelayMs?: number\n}\n\n/**\n * Wraps a fetch function with automatic retry for retriable errors\n * (network failures, 429, 5xx). Respects Retry-After headers.\n */\nexport function createRetryFetch(options?: RetryOptions): typeof globalThis.fetch {\n const maxRetries = Math.max(0, options?.maxRetries ?? 3)\n const initialDelay = options?.initialDelayMs ?? 500\n const maxDelay = options?.maxDelayMs ?? 10_000\n\n return async (input, init?) => {\n let attempt = 0\n while (true) {\n try {\n const res = await globalThis.fetch(input, init)\n if (res.ok || attempt >= maxRetries) return res\n\n const category = classifyError(res)\n if (category !== \"rate-limited\" && category !== \"server\") return res\n\n const retryAfterHeader = res.headers.get(\"Retry-After\")\n const exponentialDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n // When the header is present but unparseable, original falls back to\n // initialDelay (not exponential). Preserve that by checking presence first.\n const delay = parseRetryAfterMs(retryAfterHeader, {\n fallbackMs: retryAfterHeader?.trim() ? initialDelay : exponentialDelay,\n maxMs: maxDelay,\n })\n\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n } catch (err) {\n if (attempt >= maxRetries) throw err\n const category = classifyError(err)\n if (category !== \"network\") throw err\n\n const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n }\n }\n }\n}\n\ntype BreakerState = \"closed\" | \"open\" | \"half-open\"\n\nexport interface CircuitBreakerOptions {\n /** Number of consecutive failures to open the circuit (default: 5). */\n threshold?: number\n /** Cooldown in ms before transitioning from open to half-open (default: 30000). */\n cooldownMs?: number\n}\n\n/** Circuit breaker that prevents requests when the backend is unavailable. */\nexport class CircuitBreaker {\n private state: BreakerState = \"closed\"\n private failures = 0\n private openedAt = 0\n private readonly threshold: number\n private readonly cooldownMs: number\n\n constructor(options?: CircuitBreakerOptions) {\n this.threshold = options?.threshold ?? 5\n this.cooldownMs = options?.cooldownMs ?? 30_000\n }\n\n getState(): BreakerState {\n this.maybeTransition()\n return this.state\n }\n\n isOpen(): boolean {\n return this.getState() === \"open\"\n }\n\n recordSuccess(): void {\n this.failures = 0\n this.state = \"closed\"\n }\n\n recordFailure(): void {\n this.failures++\n if (this.state === \"half-open\" || this.failures >= this.threshold) {\n this.state = \"open\"\n this.openedAt = Date.now()\n }\n }\n\n private maybeTransition(): void {\n if (this.state === \"open\" && Date.now() - this.openedAt >= this.cooldownMs) {\n this.state = \"half-open\"\n }\n }\n}\n\n/**\n * Wraps fetch to gzip-compress string request bodies using the CompressionStream API.\n * Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)\n * are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).\n */\nexport function createCompressedFetch(inner?: typeof globalThis.fetch): typeof globalThis.fetch {\n const baseFetch = inner ?? globalThis.fetch.bind(globalThis)\n return async (input, init?) => {\n if (!init?.body || typeof CompressionStream === \"undefined\") {\n return baseFetch(input, init)\n }\n\n const bodyText = typeof init.body === \"string\" ? init.body : null\n if (!bodyText) return baseFetch(input, init)\n\n try {\n const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream(\"gzip\"))\n const compressed = await new Response(stream).arrayBuffer()\n\n const normalized = Object.fromEntries(new Headers(init.headers as HeadersInit).entries())\n normalized[\"content-encoding\"] = \"gzip\"\n\n return baseFetch(input, {\n ...init,\n body: compressed,\n headers: normalized,\n })\n } catch {\n return baseFetch(input, init)\n }\n }\n}\n\n/**\n * Wrap `fetch` to bound the **connect / Time-to-First-Byte** phase with a\n * timeout. The timer is cleared as soon as the response HEADERS arrive (i.e.\n * the `fetch()` promise resolves), so a slow large-body download after a fast\n * connection is not interrupted. Only the initial \"will the server even\n * respond?\" window is bounded.\n *\n * The wrapper composes with the caller's `AbortSignal`: if the caller's signal\n * fires first the request is still aborted and the timeout timer is cleaned up.\n *\n * @param timeoutMs How long (in ms) to wait for the server to start\n * responding before aborting. Default `10 000`.\n * @param inner Optional underlying `fetch` to wrap (defaults to\n * `globalThis.fetch`).\n *\n * @example\n * ```ts\n * import { createTimeoutFetch, createResilientFetch } from \"@drakkar.software/starfish-client/fetch\"\n *\n * const { fetch: resilient } = createResilientFetch()\n * const client = new StarfishClient({\n * baseUrl: \"https://api.example.com\",\n * fetch: createTimeoutFetch(8_000, resilient),\n * })\n * ```\n */\nexport function createTimeoutFetch(\n timeoutMs = 10_000,\n inner?: typeof globalThis.fetch,\n): typeof globalThis.fetch {\n const baseFetch = inner ?? globalThis.fetch.bind(globalThis)\n return async (input, init?) => {\n const timeoutCtrl = new AbortController()\n const timer = setTimeout(() => timeoutCtrl.abort(new Error(`connect timeout after ${timeoutMs}ms`)), timeoutMs)\n\n // Compose with a caller-supplied AbortSignal if present.\n const callerSignal = init?.signal as AbortSignal | null | undefined\n let combinedSignal: AbortSignal\n\n if (callerSignal) {\n if (typeof AbortSignal.any === \"function\") {\n combinedSignal = AbortSignal.any([timeoutCtrl.signal, callerSignal])\n } else {\n // Polyfill for environments without AbortSignal.any.\n const combo = new AbortController()\n const onCallerAbort = () => combo.abort(callerSignal.reason)\n const onTimeout = () => combo.abort(timeoutCtrl.signal.reason)\n callerSignal.addEventListener(\"abort\", onCallerAbort, { once: true })\n timeoutCtrl.signal.addEventListener(\"abort\", onTimeout, { once: true })\n combinedSignal = combo.signal\n }\n } else {\n combinedSignal = timeoutCtrl.signal\n }\n\n try {\n const res = await baseFetch(input, { ...init, signal: combinedSignal })\n clearTimeout(timer) // Headers arrived \u2014 clear the connect timeout.\n return res\n } catch (err) {\n clearTimeout(timer)\n throw err\n }\n }\n}\n\n/**\n * Combines retry and circuit breaker into a single resilient fetch wrapper.\n * Rejects immediately when the circuit is open.\n */\nexport function createResilientFetch(\n retryOptions?: RetryOptions,\n breakerOptions?: CircuitBreakerOptions,\n): { fetch: typeof globalThis.fetch; breaker: CircuitBreaker } {\n const breaker = new CircuitBreaker(breakerOptions)\n const retryFetch = createRetryFetch(retryOptions)\n\n const resilientFetch: typeof globalThis.fetch = async (input, init?) => {\n if (breaker.isOpen()) {\n const cooldown = Math.ceil((breakerOptions?.cooldownMs ?? 30_000) / 1000)\n throw new Error(`Request blocked: too many consecutive failures. Retry in ${cooldown}s.`)\n }\n\n try {\n const res = await retryFetch(input, init)\n if (res.status >= 500) {\n breaker.recordFailure()\n } else {\n breaker.recordSuccess()\n }\n return res\n } catch (err) {\n breaker.recordFailure()\n throw err\n }\n }\n\n return { fetch: resilientFetch, breaker }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { AppendLogCursor, AppendAuthorError, checkpointOf } from "./append-log.j
12
12
  export type { AppendLogCursorOptions, AppendElement, AuthorVerifier, ElementErrorPolicy } from "./append-log.js";
13
13
  export { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
14
14
  export type { Encryptor } from "@drakkar.software/starfish-protocol";
15
- export { ConflictError, StarfishHttpError, } from "./types.js";
15
+ export { ConflictError, StarfishHttpError, AppendHttpError, } from "./types.js";
16
16
  export type { StarfishClientOptions, StarfishCapProvider, PullCache, ConflictResolver, ClientPlugin, } from "./types.js";
17
17
  export { consoleSyncLogger, noopSyncLogger, createMetricsCollector } from "./logger.js";
18
18
  export type { SyncLogger, SyncMetrics, MetricsCollector } from "./logger.js";
@@ -35,6 +35,8 @@ export { fetchServerConfig } from "./config.js";
35
35
  export type { EncryptionMode, CollectionClientInfo, ConfigResponse } from "./config.js";
36
36
  export { createIndexedDBStorage } from "./storage/indexeddb.js";
37
37
  export type { IndexedDBStorageOptions, AsyncStateStorage } from "./storage/indexeddb.js";
38
+ export { createKvPullCache } from "./kv-cache.js";
39
+ export type { KvStore, KvPullCacheOptions } from "./kv-cache.js";
38
40
  export { exportData, importData, exportToBlob } from "./export.js";
39
41
  export type { ExportOptions } from "./export.js";
40
42
  export { isBackgroundSyncSupported, registerBackgroundSync } from "./background-sync.js";
package/dist/index.js CHANGED
@@ -38,6 +38,13 @@ var StarfishHttpError = class extends Error {
38
38
  this.name = "StarfishHttpError";
39
39
  }
40
40
  };
41
+ var AppendHttpError = class extends Error {
42
+ constructor(status, message) {
43
+ super(message);
44
+ this.status = status;
45
+ this.name = "AppendHttpError";
46
+ }
47
+ };
41
48
 
42
49
  // src/fetch.ts
43
50
  function parseRetryAfterMs(header, opts) {
@@ -537,6 +544,62 @@ var StarfishClient = class {
537
544
  }
538
545
  return res.json();
539
546
  }
547
+ /**
548
+ * Append one element to a **public-write** append-only collection with an
549
+ * Ed25519 author proof but **no cap `Authorization` header**.
550
+ *
551
+ * Unlike {@link append}, which always attaches a cap-signed `Authorization`
552
+ * header from the configured `capProvider`, this method signs only the
553
+ * append-author proof (binding the element to the writer's Ed25519 key) and
554
+ * sends the request without authentication headers. This is required for
555
+ * collections with `writeRoles: ["public"]` — the server's cap-scope check
556
+ * would reject a request carrying a cap whose scope does not cover the path.
557
+ *
558
+ * Typical use-case: writing a sealed invitation to another user's
559
+ * public-write inbox collection without needing a cap scoped to the
560
+ * recipient's namespace. The author proof is optional on the server side
561
+ * (`requireAuthorSignature: false` for a public inbox), but signing anyway
562
+ * binds the stored element to the sender's Ed25519 key for verification in
563
+ * the receive path.
564
+ *
565
+ * The element is sent as `{ data, authorPubkey, authorSignature }`.
566
+ *
567
+ * @param path The push path, e.g. `/push/inbox/{userId}/{shard}`.
568
+ * @param element The JSON element to append.
569
+ * @param signer The sender's Ed25519 keypair (signs the author proof).
570
+ *
571
+ * @throws {AppendHttpError} on a non-2xx response.
572
+ */
573
+ async appendAnonymous(path, element, signer) {
574
+ const sendPath = this.applyNamespace(path);
575
+ const documentKey = stripPushPrefix(path);
576
+ const { authorPubkey, authorSignature } = signAppendAuthor(
577
+ documentKey,
578
+ element,
579
+ signer.edPubHex,
580
+ signer.edPrivHex
581
+ );
582
+ const body = JSON.stringify({
583
+ [DATA_FIELD]: element,
584
+ [AUTHOR_PUBKEY_FIELD]: authorPubkey,
585
+ [AUTHOR_SIGNATURE_FIELD]: authorSignature
586
+ });
587
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
588
+ method: "POST",
589
+ headers: {
590
+ [HEADER_CONTENT_TYPE]: "application/json",
591
+ [HEADER_ACCEPT]: "application/json"
592
+ },
593
+ body
594
+ });
595
+ if (!res.ok) {
596
+ const detail = await res.text().catch(() => "");
597
+ throw new AppendHttpError(
598
+ res.status,
599
+ `anonymous append failed: HTTP ${res.status} ${detail}`.trim()
600
+ );
601
+ }
602
+ }
540
603
  /**
541
604
  * Pull binary data from a blob collection.
542
605
  * Returns raw bytes with the content hash from the ETag header.
@@ -1499,6 +1562,46 @@ function createIndexedDBStorage(opts) {
1499
1562
  };
1500
1563
  }
1501
1564
 
1565
+ // src/kv-cache.ts
1566
+ function createKvPullCache(kv, opts = {}) {
1567
+ const prefix = opts.prefix ?? "starfish.pullcache.";
1568
+ const maxAgeMs = opts.maxAgeMs;
1569
+ return {
1570
+ async get(key) {
1571
+ try {
1572
+ const raw = await kv.getItem(prefix + key);
1573
+ if (raw === null || raw === void 0) return null;
1574
+ let payload;
1575
+ let cachedAt;
1576
+ try {
1577
+ const envelope = JSON.parse(raw);
1578
+ if (typeof envelope.payload === "string") {
1579
+ payload = envelope.payload;
1580
+ cachedAt = envelope._cachedAt;
1581
+ } else {
1582
+ payload = raw;
1583
+ }
1584
+ } catch {
1585
+ payload = raw;
1586
+ }
1587
+ if (maxAgeMs !== void 0 && cachedAt !== void 0) {
1588
+ if (Date.now() - cachedAt > maxAgeMs) return null;
1589
+ }
1590
+ return payload;
1591
+ } catch {
1592
+ return null;
1593
+ }
1594
+ },
1595
+ async set(key, value) {
1596
+ try {
1597
+ const envelope = { payload: value, _cachedAt: Date.now() };
1598
+ await kv.setItem(prefix + key, JSON.stringify(envelope));
1599
+ } catch {
1600
+ }
1601
+ }
1602
+ };
1603
+ }
1604
+
1502
1605
  // src/export.ts
1503
1606
  function exportData(data, opts) {
1504
1607
  const format = opts?.format ?? "json";
@@ -1878,6 +1981,7 @@ function createMultiStoreSync(options) {
1878
1981
  export {
1879
1982
  AbortError,
1880
1983
  AppendAuthorError,
1984
+ AppendHttpError,
1881
1985
  AppendLogCursor,
1882
1986
  ConflictError,
1883
1987
  ENCRYPTED_KEY,
@@ -1897,6 +2001,7 @@ export {
1897
2001
  createDebouncedSync,
1898
2002
  createDedupFetch,
1899
2003
  createIndexedDBStorage,
2004
+ createKvPullCache,
1900
2005
  createMetricsCollector,
1901
2006
  createMigrator,
1902
2007
  createMobileLifecycle,