@drakkar.software/starfish-client 3.0.0-alpha.29 → 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/bindings/zustand.d.ts +67 -1
- package/dist/bindings/zustand.js +140 -1
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +30 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/fetch.d.ts +27 -0
- package/dist/fetch.js +32 -0
- package/dist/fetch.js.map +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +105 -0
- package/dist/index.js.map +3 -3
- package/dist/kv-cache.d.ts +63 -0
- package/dist/types.d.ts +14 -0
- package/package.json +6 -2
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.
|
package/dist/events.d.ts
ADDED
|
@@ -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": "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",
|
|
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,
|