@drakkar.software/starfish-client 3.0.0-alpha.5 → 3.0.0-alpha.51
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/README.md +59 -0
- package/dist/append-log.d.ts +228 -0
- package/dist/append-log.js +267 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/legend.d.ts +23 -0
- package/dist/bindings/legend.js +32 -0
- package/dist/bindings/legend.js.map +2 -2
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.d.ts +167 -2
- package/dist/bindings/zustand.js +942 -82
- package/dist/bindings/zustand.js.map +4 -4
- package/dist/blob-seal.d.ts +123 -0
- package/dist/client.d.ts +270 -5
- package/dist/client.js +391 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +18 -0
- package/dist/debounced-sync.js +120 -0
- package/dist/dedup.js +35 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/export.js +115 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +51 -14
- package/dist/fetch.js.map +2 -2
- package/dist/history.js +61 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +1030 -94
- package/dist/index.js.map +4 -4
- package/dist/kv-cache.d.ts +63 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +80 -0
- package/dist/migrate.js +38 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mobile-lifecycle.js +94 -0
- package/dist/multi-store.js +92 -0
- package/dist/mutate.d.ts +39 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.js +223 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.d.ts +83 -0
- package/dist/sync.js +181 -0
- package/dist/types.d.ts +106 -11
- package/dist/types.js +18 -0
- package/dist/validate.js +28 -0
- package/package.json +12 -3
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/export.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data export/import helpers for Starfish sync data.
|
|
3
|
+
* Supports JSON and CSV formats.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Export data to a string representation.
|
|
7
|
+
* JSON: serializes the full object.
|
|
8
|
+
* CSV: flattens top-level keys into columns. Array values are JSON-encoded.
|
|
9
|
+
*/
|
|
10
|
+
export function exportData(data, opts) {
|
|
11
|
+
const format = opts?.format ?? "json";
|
|
12
|
+
if (format === "json") {
|
|
13
|
+
return opts?.pretty
|
|
14
|
+
? JSON.stringify(data, null, 2)
|
|
15
|
+
: JSON.stringify(data);
|
|
16
|
+
}
|
|
17
|
+
// CSV export: each top-level key becomes a column
|
|
18
|
+
return toCsv(data);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Import data from a string representation.
|
|
22
|
+
*/
|
|
23
|
+
export function importData(raw, format = "json") {
|
|
24
|
+
if (format === "json") {
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
27
|
+
throw new Error("Expected a JSON object");
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
return fromCsv(raw);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Export data to a Blob suitable for download.
|
|
35
|
+
*/
|
|
36
|
+
export function exportToBlob(data, opts) {
|
|
37
|
+
const format = opts?.format ?? "json";
|
|
38
|
+
const content = exportData(data, opts);
|
|
39
|
+
const mimeType = format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8";
|
|
40
|
+
return new Blob([content], { type: mimeType });
|
|
41
|
+
}
|
|
42
|
+
function toCsv(data) {
|
|
43
|
+
const keys = Object.keys(data);
|
|
44
|
+
const header = keys.map(escapeCsvField).join(",");
|
|
45
|
+
const values = keys.map((k) => {
|
|
46
|
+
const v = data[k];
|
|
47
|
+
if (v === null || v === undefined)
|
|
48
|
+
return "";
|
|
49
|
+
if (typeof v === "object")
|
|
50
|
+
return escapeCsvField(JSON.stringify(v));
|
|
51
|
+
return escapeCsvField(String(v));
|
|
52
|
+
});
|
|
53
|
+
return `${header}\n${values.join(",")}`;
|
|
54
|
+
}
|
|
55
|
+
function fromCsv(raw) {
|
|
56
|
+
const lines = raw.trim().split("\n");
|
|
57
|
+
if (lines.length < 2) {
|
|
58
|
+
throw new Error("CSV must have at least a header row and a data row");
|
|
59
|
+
}
|
|
60
|
+
const headers = parseCsvLine(lines[0]);
|
|
61
|
+
const values = parseCsvLine(lines[1]);
|
|
62
|
+
const result = {};
|
|
63
|
+
for (let i = 0; i < headers.length; i++) {
|
|
64
|
+
const key = headers[i];
|
|
65
|
+
const val = values[i] ?? "";
|
|
66
|
+
// Try to parse JSON values
|
|
67
|
+
try {
|
|
68
|
+
result[key] = JSON.parse(val);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
result[key] = val;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function escapeCsvField(field) {
|
|
77
|
+
if (field.includes(",") || field.includes('"') || field.includes("\n")) {
|
|
78
|
+
return `"${field.replace(/"/g, '""')}"`;
|
|
79
|
+
}
|
|
80
|
+
return field;
|
|
81
|
+
}
|
|
82
|
+
function parseCsvLine(line) {
|
|
83
|
+
const result = [];
|
|
84
|
+
let current = "";
|
|
85
|
+
let inQuotes = false;
|
|
86
|
+
for (let i = 0; i < line.length; i++) {
|
|
87
|
+
const ch = line[i];
|
|
88
|
+
if (inQuotes) {
|
|
89
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
90
|
+
current += '"';
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
else if (ch === '"') {
|
|
94
|
+
inQuotes = false;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
current += ch;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (ch === '"') {
|
|
102
|
+
inQuotes = true;
|
|
103
|
+
}
|
|
104
|
+
else if (ch === ",") {
|
|
105
|
+
result.push(current);
|
|
106
|
+
current = "";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
current += ch;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
result.push(current);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
package/dist/fetch.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a `Retry-After` header value into milliseconds.
|
|
3
|
+
*
|
|
4
|
+
* - Numeric string (`"30"`) — treated as seconds × 1000.
|
|
5
|
+
* - HTTP-date string — delta from now in ms (floored to 0).
|
|
6
|
+
* - `null`, empty, or unparseable — returns `opts.fallbackMs`.
|
|
7
|
+
*
|
|
8
|
+
* All results are clamped to `[0, opts.maxMs]`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseRetryAfterMs(header: string | null | undefined, opts: {
|
|
11
|
+
fallbackMs: number;
|
|
12
|
+
maxMs: number;
|
|
13
|
+
}): number;
|
|
1
14
|
/** Error category returned by classifyError. */
|
|
2
15
|
export type ErrorCategory = "network" | "auth" | "conflict" | "rate-limited" | "server" | "client" | "unknown";
|
|
3
16
|
/** Classify an error from a fetch response or network failure. */
|
|
@@ -42,6 +55,33 @@ export declare class CircuitBreaker {
|
|
|
42
55
|
* are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).
|
|
43
56
|
*/
|
|
44
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;
|
|
45
85
|
/**
|
|
46
86
|
* Combines retry and circuit breaker into a single resilient fetch wrapper.
|
|
47
87
|
* Rejects immediately when the circuit is open.
|
package/dist/fetch.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
// src/fetch.ts
|
|
2
|
+
function parseRetryAfterMs(header, opts) {
|
|
3
|
+
const { fallbackMs, maxMs } = opts;
|
|
4
|
+
const trimmed = header?.trim();
|
|
5
|
+
if (trimmed) {
|
|
6
|
+
const seconds = Number(trimmed);
|
|
7
|
+
if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
|
|
8
|
+
const date = Date.parse(trimmed);
|
|
9
|
+
if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
|
|
10
|
+
}
|
|
11
|
+
return Math.min(fallbackMs, maxMs);
|
|
12
|
+
}
|
|
2
13
|
function classifyError(err) {
|
|
3
14
|
if (err instanceof Response || err && typeof err === "object" && "status" in err) {
|
|
4
15
|
const status = err.status;
|
|
@@ -25,19 +36,12 @@ function createRetryFetch(options) {
|
|
|
25
36
|
if (res.ok || attempt >= maxRetries) return res;
|
|
26
37
|
const category = classifyError(res);
|
|
27
38
|
if (category !== "rate-limited" && category !== "server") return res;
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} else {
|
|
35
|
-
const date = Date.parse(retryAfter);
|
|
36
|
-
delay = isNaN(date) ? initialDelay : Math.min(Math.max(date - Date.now(), 0), maxDelay);
|
|
37
|
-
}
|
|
38
|
-
} else {
|
|
39
|
-
delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
|
|
40
|
-
}
|
|
39
|
+
const retryAfterHeader = res.headers.get("Retry-After");
|
|
40
|
+
const exponentialDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
|
|
41
|
+
const delay = parseRetryAfterMs(retryAfterHeader, {
|
|
42
|
+
fallbackMs: retryAfterHeader?.trim() ? initialDelay : exponentialDelay,
|
|
43
|
+
maxMs: maxDelay
|
|
44
|
+
});
|
|
41
45
|
await new Promise((r) => setTimeout(r, delay));
|
|
42
46
|
attempt++;
|
|
43
47
|
} catch (err) {
|
|
@@ -108,6 +112,37 @@ function createCompressedFetch(inner) {
|
|
|
108
112
|
}
|
|
109
113
|
};
|
|
110
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
|
+
}
|
|
111
146
|
function createResilientFetch(retryOptions, breakerOptions) {
|
|
112
147
|
const breaker = new CircuitBreaker(breakerOptions);
|
|
113
148
|
const retryFetch = createRetryFetch(retryOptions);
|
|
@@ -136,6 +171,8 @@ export {
|
|
|
136
171
|
classifyError,
|
|
137
172
|
createCompressedFetch,
|
|
138
173
|
createResilientFetch,
|
|
139
|
-
createRetryFetch
|
|
174
|
+
createRetryFetch,
|
|
175
|
+
createTimeoutFetch,
|
|
176
|
+
parseRetryAfterMs
|
|
140
177
|
};
|
|
141
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": ["/** 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 retryAfter = res.headers.get(\"Retry-After\")?.trim()\n let delay: number\n if (retryAfter) {\n const seconds = Number(retryAfter)\n if (retryAfter !== \"\" && !isNaN(seconds)) {\n delay = Math.min(seconds * 1000, maxDelay)\n } else {\n const date = Date.parse(retryAfter)\n delay = isNaN(date) ? initialDelay : Math.min(Math.max(date - Date.now(), 0), maxDelay)\n }\n } else {\n delay = Math.min(initialDelay * Math.pow(2, attempt), 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": ";
|
|
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
|
}
|