@chr33s/solarflare 0.0.2

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.
Files changed (47) hide show
  1. package/package.json +52 -0
  2. package/readme.md +183 -0
  3. package/src/ast.ts +316 -0
  4. package/src/build.bundle-client.ts +404 -0
  5. package/src/build.bundle-server.ts +131 -0
  6. package/src/build.bundle.ts +48 -0
  7. package/src/build.emit-manifests.ts +25 -0
  8. package/src/build.hmr-entry.ts +88 -0
  9. package/src/build.scan.ts +182 -0
  10. package/src/build.ts +227 -0
  11. package/src/build.validate.ts +63 -0
  12. package/src/client.hmr.ts +78 -0
  13. package/src/client.styles.ts +68 -0
  14. package/src/client.ts +190 -0
  15. package/src/codemod.ts +688 -0
  16. package/src/console-forward.ts +254 -0
  17. package/src/critical-css.ts +103 -0
  18. package/src/devtools-json.ts +52 -0
  19. package/src/diff-dom-streaming.ts +406 -0
  20. package/src/early-flush.ts +125 -0
  21. package/src/early-hints.ts +83 -0
  22. package/src/fetch.ts +44 -0
  23. package/src/fs.ts +11 -0
  24. package/src/head.ts +876 -0
  25. package/src/hmr.ts +647 -0
  26. package/src/hydration.ts +238 -0
  27. package/src/manifest.runtime.ts +25 -0
  28. package/src/manifest.ts +23 -0
  29. package/src/paths.ts +96 -0
  30. package/src/render-priority.ts +69 -0
  31. package/src/route-cache.ts +163 -0
  32. package/src/router-deferred.ts +85 -0
  33. package/src/router-stream.ts +65 -0
  34. package/src/router.ts +535 -0
  35. package/src/runtime.ts +32 -0
  36. package/src/serialize.ts +38 -0
  37. package/src/server.hmr.ts +67 -0
  38. package/src/server.styles.ts +42 -0
  39. package/src/server.ts +480 -0
  40. package/src/solarflare.d.ts +101 -0
  41. package/src/speculation-rules.ts +171 -0
  42. package/src/store.ts +78 -0
  43. package/src/stream-assets.ts +135 -0
  44. package/src/stylesheets.ts +222 -0
  45. package/src/worker.config.ts +243 -0
  46. package/src/worker.ts +542 -0
  47. package/tsconfig.json +21 -0
@@ -0,0 +1,406 @@
1
+ // src: https://github.com/brisa-build/diff-dom-streaming/blob/main/src/index.ts
2
+
3
+ type Walker = {
4
+ root: Node | null;
5
+ [FIRST_CHILD]: (node: Node) => Promise<Node | null>;
6
+ [NEXT_SIBLING]: (node: Node) => Promise<Node | null>;
7
+ [APPLY_TRANSITION]: (v: () => void) => void;
8
+ [FLUSH_SYNC]: () => void;
9
+ };
10
+
11
+ type NextNodeCallback = (node: Node) => void;
12
+
13
+ type Options = {
14
+ onNextNode?: NextNodeCallback;
15
+ transition?: boolean;
16
+ shouldIgnoreNode?: (node: Node | null) => boolean;
17
+ /** Called after each stream chunk is processed - use to flush pending mutations. */
18
+ onChunkProcessed?: () => void;
19
+ /** Apply mutations synchronously instead of batching via requestAnimationFrame. */
20
+ syncMutations?: boolean;
21
+ };
22
+
23
+ const ELEMENT_TYPE = 1;
24
+ const DOCUMENT_TYPE = 9;
25
+ const DOCUMENT_FRAGMENT_TYPE = 11;
26
+ const APPLY_TRANSITION = 0;
27
+ const FIRST_CHILD = 1;
28
+ const NEXT_SIBLING = 2;
29
+ const FLUSH_SYNC = 3;
30
+ const SPECIAL_TAGS = new Set(["HTML", "HEAD", "BODY"]);
31
+ const wait = () => new Promise((resolve) => requestAnimationFrame(resolve));
32
+
33
+ export default async function diff(oldNode: Node, stream: ReadableStream, options?: Options) {
34
+ const walker = await htmlStreamWalker(stream, options);
35
+ const newNode = walker.root!;
36
+
37
+ if (oldNode.nodeType === DOCUMENT_TYPE) {
38
+ oldNode = (oldNode as Document).documentElement;
39
+ }
40
+
41
+ if (newNode.nodeType === DOCUMENT_FRAGMENT_TYPE) {
42
+ await setChildNodes(oldNode, newNode, walker);
43
+ } else {
44
+ await updateNode(oldNode, newNode, walker);
45
+ }
46
+
47
+ // Flush any remaining batched mutations before returning.
48
+ // Without this, mutations scheduled via requestAnimationFrame may not
49
+ // be applied before the caller continues, causing blank pages.
50
+ walker[FLUSH_SYNC]();
51
+ }
52
+
53
+ /**
54
+ * Updates a specific htmlNode and does whatever it takes to convert it to another one.
55
+ */
56
+ async function updateNode(oldNode: Node, newNode: Node, walker: Walker) {
57
+ if (oldNode.nodeType !== newNode.nodeType) {
58
+ return walker[APPLY_TRANSITION](() => {
59
+ // oldNode may have been moved/removed by a previous batched mutation
60
+ if (oldNode.parentNode) {
61
+ oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
62
+ }
63
+ });
64
+ }
65
+
66
+ if (oldNode.nodeType === ELEMENT_TYPE) {
67
+ await setChildNodes(oldNode, newNode, walker);
68
+
69
+ walker[APPLY_TRANSITION](() => {
70
+ if (oldNode.nodeName === newNode.nodeName) {
71
+ if (newNode.nodeName !== "BODY") {
72
+ setAttributes((oldNode as Element).attributes, (newNode as Element).attributes);
73
+ }
74
+ } else {
75
+ const hasDocumentFragmentInside = newNode.nodeName === "TEMPLATE";
76
+ const clonedNewNode = newNode.cloneNode(hasDocumentFragmentInside);
77
+ while (oldNode.firstChild) clonedNewNode.appendChild(oldNode.firstChild);
78
+ // oldNode may have been moved/removed by a previous batched mutation
79
+ if (oldNode.parentNode) {
80
+ oldNode.parentNode.replaceChild(clonedNewNode, oldNode);
81
+ }
82
+ }
83
+ });
84
+ } else if (oldNode.nodeValue !== newNode.nodeValue) {
85
+ walker[APPLY_TRANSITION](() => (oldNode.nodeValue = newNode.nodeValue));
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Utility that will update one list of attributes to match another.
91
+ */
92
+ function setAttributes(oldAttributes: NamedNodeMap, newAttributes: NamedNodeMap) {
93
+ let i, oldAttribute, newAttribute, namespace, name;
94
+
95
+ // Remove old attributes.
96
+ for (i = oldAttributes.length; i--; ) {
97
+ oldAttribute = oldAttributes[i];
98
+ namespace = oldAttribute.namespaceURI;
99
+ name = oldAttribute.localName;
100
+ newAttribute = newAttributes.getNamedItemNS(namespace, name);
101
+
102
+ if (!newAttribute) oldAttributes.removeNamedItemNS(namespace, name);
103
+ }
104
+
105
+ // Set new attributes.
106
+ for (i = newAttributes.length; i--; ) {
107
+ oldAttribute = newAttributes[i];
108
+ namespace = oldAttribute.namespaceURI;
109
+ name = oldAttribute.localName;
110
+ newAttribute = oldAttributes.getNamedItemNS(namespace, name);
111
+
112
+ // Avoid register already registered server action in frameworks like Brisa
113
+ if (oldAttribute.name === "data-action") continue;
114
+
115
+ if (!newAttribute) {
116
+ // Add a new attribute.
117
+ newAttributes.removeNamedItemNS(namespace, name);
118
+ oldAttributes.setNamedItemNS(oldAttribute);
119
+ } else if (newAttribute.value !== oldAttribute.value) {
120
+ // Update existing attribute.
121
+ newAttribute.value = oldAttribute.value;
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Utility that will nodes childern to match another nodes children.
128
+ */
129
+ async function setChildNodes(oldParent: Node, newParent: Node, walker: Walker) {
130
+ let checkOld;
131
+ let oldKey;
132
+ let newKey;
133
+ let foundNode;
134
+ let keyedNodes: Record<string, Node> | null = null;
135
+ let oldNode = oldParent.firstChild;
136
+ let newNode = await walker[FIRST_CHILD](newParent);
137
+ let extra = 0;
138
+ let pendingFragment: DocumentFragment | null = null;
139
+ let pendingBefore: ChildNode | null = null;
140
+
141
+ const createFragment = () =>
142
+ oldParent.ownerDocument?.createDocumentFragment?.() ?? document.createDocumentFragment();
143
+
144
+ const flushPendingInsertions = () => {
145
+ if (!pendingFragment) return;
146
+ const fragment = pendingFragment;
147
+ const before = pendingBefore;
148
+ pendingFragment = null;
149
+ pendingBefore = null;
150
+ walker[APPLY_TRANSITION](() => {
151
+ if (before && before.parentNode === oldParent) {
152
+ oldParent.insertBefore(fragment, before);
153
+ } else {
154
+ oldParent.appendChild(fragment);
155
+ }
156
+ });
157
+ };
158
+
159
+ const queueInsertion = (node: Node, before: ChildNode | null) => {
160
+ if (!pendingFragment || pendingBefore !== before) {
161
+ flushPendingInsertions();
162
+ pendingFragment = createFragment();
163
+ pendingBefore = before;
164
+ }
165
+ pendingFragment.appendChild(node);
166
+ };
167
+
168
+ const shouldInsertImmediately = (node: Node) => {
169
+ if (node.nodeType !== ELEMENT_TYPE) return false;
170
+ const el = node as Element;
171
+ return el.tagName === "SCRIPT";
172
+ };
173
+
174
+ // Extract keyed nodes from previous children and keep track of total count.
175
+ while (oldNode) {
176
+ extra++;
177
+ checkOld = oldNode;
178
+ oldKey = getKey(checkOld);
179
+ oldNode = oldNode.nextSibling;
180
+
181
+ if (oldKey) {
182
+ if (!keyedNodes) keyedNodes = {};
183
+ keyedNodes[oldKey] = checkOld;
184
+ }
185
+ }
186
+
187
+ oldNode = oldParent.firstChild;
188
+
189
+ // Loop over new nodes and perform updates.
190
+ while (newNode) {
191
+ let insertedNode;
192
+
193
+ if (keyedNodes && (newKey = getKey(newNode)) && (foundNode = keyedNodes[newKey])) {
194
+ flushPendingInsertions();
195
+ delete keyedNodes[newKey];
196
+ if (foundNode !== oldNode) {
197
+ walker[APPLY_TRANSITION](() => {
198
+ // oldNode may have been moved/removed by a previous batched mutation
199
+ if (oldNode && oldNode.parentNode === oldParent) {
200
+ oldParent.insertBefore(foundNode!, oldNode);
201
+ } else {
202
+ oldParent.appendChild(foundNode!);
203
+ }
204
+ });
205
+ // Flush immediately so subsequent iterations see correct DOM order
206
+ walker[FLUSH_SYNC]();
207
+ } else {
208
+ oldNode = oldNode.nextSibling;
209
+ }
210
+
211
+ await updateNode(foundNode, newNode, walker);
212
+ } else if (oldNode) {
213
+ checkOld = oldNode;
214
+ oldNode = oldNode.nextSibling;
215
+ if (getKey(checkOld)) {
216
+ insertedNode = newNode.cloneNode(true);
217
+ if (shouldInsertImmediately(insertedNode!)) {
218
+ flushPendingInsertions();
219
+ walker[APPLY_TRANSITION](() => {
220
+ if (checkOld!.parentNode === oldParent) {
221
+ oldParent.insertBefore(insertedNode!, checkOld!);
222
+ } else {
223
+ oldParent.appendChild(insertedNode!);
224
+ }
225
+ });
226
+ } else {
227
+ queueInsertion(
228
+ insertedNode!,
229
+ checkOld!.parentNode === oldParent ? (checkOld as ChildNode) : null,
230
+ );
231
+ }
232
+ } else {
233
+ flushPendingInsertions();
234
+ await updateNode(checkOld, newNode, walker);
235
+ }
236
+ } else {
237
+ insertedNode = newNode.cloneNode(true);
238
+ if (shouldInsertImmediately(insertedNode!)) {
239
+ flushPendingInsertions();
240
+ walker[APPLY_TRANSITION](() => oldParent.appendChild(insertedNode!));
241
+ } else {
242
+ queueInsertion(insertedNode!, null);
243
+ }
244
+ }
245
+
246
+ newNode = (await walker[NEXT_SIBLING](newNode)) as ChildNode;
247
+
248
+ // If we didn't insert a node this means we are updating an existing one, so we
249
+ // need to decrement the extra counter, so we can skip removing the old node.
250
+ if (!insertedNode) extra--;
251
+ }
252
+
253
+ flushPendingInsertions();
254
+
255
+ walker[APPLY_TRANSITION](() => {
256
+ // Remove old keyed nodes.
257
+ for (oldKey in keyedNodes) {
258
+ const node = keyedNodes![oldKey]!;
259
+ // Node may have been moved/removed by a previous batched mutation
260
+ if (node.parentNode === oldParent) {
261
+ extra--;
262
+ oldParent.removeChild(node);
263
+ }
264
+ }
265
+
266
+ // If we have any remaining unkeyed nodes remove them from the end.
267
+ while (--extra >= 0 && oldParent.lastChild) oldParent.removeChild(oldParent.lastChild);
268
+ });
269
+ }
270
+
271
+ function getKey(node: Node) {
272
+ return (node as Element)?.getAttribute?.("key") || (node as Element).id;
273
+ }
274
+
275
+ /**
276
+ * Utility that will walk a html stream and call a callback for each node.
277
+ */
278
+ async function htmlStreamWalker(stream: ReadableStream, options: Options = {}) {
279
+ const doc = document.implementation.createHTMLDocument();
280
+
281
+ doc.open();
282
+ const decoderStream = new TextDecoderStream();
283
+ const decoderStreamReader = decoderStream.readable.getReader();
284
+ let streamInProgress = true;
285
+
286
+ // Batch mutations when View Transitions unavailable to prevent Preact vdom corruption
287
+ let pendingMutations: (() => void)[] = [];
288
+ let flushScheduled = false;
289
+
290
+ function flushMutations() {
291
+ const mutations = pendingMutations;
292
+ pendingMutations = [];
293
+ flushScheduled = false;
294
+ for (const mutation of mutations) {
295
+ mutation();
296
+ }
297
+ }
298
+
299
+ function flushMutationsSync() {
300
+ if (pendingMutations.length > 0) {
301
+ flushMutations();
302
+ }
303
+ }
304
+
305
+ void stream.pipeTo(decoderStream.writable);
306
+ void processStream();
307
+
308
+ async function processStream() {
309
+ try {
310
+ while (true) {
311
+ const { done, value } = await decoderStreamReader.read();
312
+ if (done) {
313
+ streamInProgress = false;
314
+ break;
315
+ }
316
+
317
+ doc.write(value);
318
+
319
+ // Call chunk callback to allow flushing pending mutations progressively
320
+ options.onChunkProcessed?.();
321
+ }
322
+ } finally {
323
+ doc.close();
324
+ }
325
+ }
326
+
327
+ while (!doc.documentElement || isLastNodeOfChunk(doc.documentElement)) {
328
+ await wait();
329
+ }
330
+
331
+ function next(field: "firstChild" | "nextSibling") {
332
+ return async (node: Node) => {
333
+ if (!node) return null;
334
+
335
+ let nextNode = node[field];
336
+
337
+ while (options.shouldIgnoreNode?.(nextNode)) {
338
+ nextNode = nextNode![field];
339
+ }
340
+
341
+ if (nextNode) options.onNextNode?.(nextNode);
342
+
343
+ const waitChildren = field === "firstChild";
344
+
345
+ while (isLastNodeOfChunk(nextNode as Element, waitChildren)) {
346
+ await wait();
347
+ }
348
+
349
+ return nextNode;
350
+ };
351
+ }
352
+
353
+ function isLastNodeOfChunk(node: Node, waitChildren?: boolean) {
354
+ if (!node || !streamInProgress || node.nextSibling) {
355
+ return false;
356
+ }
357
+
358
+ if (SPECIAL_TAGS.has(node.nodeName)) {
359
+ return !doc.body?.hasChildNodes?.();
360
+ }
361
+
362
+ let parent = node.parentElement;
363
+
364
+ while (parent) {
365
+ if (parent.nextSibling) return false;
366
+ parent = parent.parentElement;
367
+ }
368
+
369
+ // Related issues to this ternary (hard to reproduce in a test):
370
+ // https://github.com/brisa-build/diff-dom-streaming/pull/15
371
+ // https://github.com/brisa-build/brisa/issues/739
372
+ return waitChildren ? streamInProgress && !node.hasChildNodes?.() : streamInProgress;
373
+ }
374
+
375
+ return {
376
+ root: doc.documentElement,
377
+ [FIRST_CHILD]: next("firstChild"),
378
+ [NEXT_SIBLING]: next("nextSibling"),
379
+ [APPLY_TRANSITION]: (v: () => void) => {
380
+ if (options.transition && document.startViewTransition) {
381
+ // Collect all view transitions so the caller can await them all.
382
+ // Previously we only stored the last transition, causing earlier
383
+ // mutations (like body content insertion) to be missed.
384
+ const transition = document.startViewTransition(v);
385
+ const transitions: ViewTransition[] =
386
+ (window as any).lastDiffTransitions ?? ((window as any).lastDiffTransitions = []);
387
+ transitions.push(transition);
388
+ // Keep lastDiffTransition for backwards compatibility
389
+ // @ts-expect-error - expose for router to await
390
+ window.lastDiffTransition = transition;
391
+ } else if (options.syncMutations) {
392
+ // Apply mutations synchronously for progressive streaming of deferred content
393
+ v();
394
+ } else {
395
+ // Batch mutations via requestAnimationFrame to prevent Preact custom element
396
+ // vdom corruption when diff patches inside a mounted element's subtree.
397
+ pendingMutations.push(v);
398
+ if (!flushScheduled) {
399
+ flushScheduled = true;
400
+ requestAnimationFrame(flushMutations);
401
+ }
402
+ }
403
+ },
404
+ [FLUSH_SYNC]: flushMutationsSync,
405
+ };
406
+ }
@@ -0,0 +1,125 @@
1
+ /** Static shell that can be sent before rendering. */
2
+ export interface StreamingShell {
3
+ preHead: string;
4
+ preBody: string;
5
+ headMarker: string;
6
+ bodyMarker: string;
7
+ }
8
+
9
+ /** Generates a static shell from layout analysis. */
10
+ export function generateStaticShell(options: {
11
+ lang?: string;
12
+ charset?: string;
13
+ viewport?: string;
14
+ }) {
15
+ const {
16
+ lang = "en",
17
+ charset = "UTF-8",
18
+ viewport = "width=device-width, initial-scale=1",
19
+ } = options;
20
+
21
+ return {
22
+ preHead: /* html */ `
23
+ <!DOCTYPE html>
24
+ <html lang="${lang}">
25
+ <head>
26
+ <meta charset="${charset}">
27
+ <meta name="viewport" content="${viewport}">
28
+ `,
29
+ headMarker: "<!--SF: HEAD-->",
30
+ preBody: /* html */ `
31
+ </head>
32
+ <body>
33
+ `,
34
+ bodyMarker: "<!--SF: BODY-->",
35
+ };
36
+ }
37
+
38
+ /** Creates a streaming response with early flush. */
39
+ export function createEarlyFlushStream(
40
+ shell: StreamingShell,
41
+ options: {
42
+ criticalCss?: string;
43
+ preloadHints?: string;
44
+ contentStream: ReadableStream<Uint8Array>;
45
+ headTags: string;
46
+ bodyTags: string;
47
+ },
48
+ ) {
49
+ const encoder = new TextEncoder();
50
+ let contentReader: ReadableStreamDefaultReader<Uint8Array>;
51
+ let phase: "shell" | "content" | "done" = "shell";
52
+
53
+ return new ReadableStream<Uint8Array>({
54
+ async start(controller) {
55
+ const shellStart = [
56
+ shell.preHead,
57
+ options.preloadHints || "",
58
+ options.criticalCss ? /* html */ `<style>${options.criticalCss}</style>` : "",
59
+ ].join("");
60
+
61
+ controller.enqueue(encoder.encode(shellStart));
62
+
63
+ controller.enqueue(encoder.encode(options.headTags));
64
+ controller.enqueue(encoder.encode(shell.preBody));
65
+
66
+ phase = "content";
67
+ contentReader = options.contentStream.getReader();
68
+ },
69
+
70
+ async pull(controller) {
71
+ if (phase === "done") {
72
+ controller.close();
73
+ return;
74
+ }
75
+
76
+ try {
77
+ const { done, value } = await contentReader.read();
78
+
79
+ if (done) {
80
+ controller.enqueue(encoder.encode(options.bodyTags));
81
+ controller.enqueue(encoder.encode("</body></html>"));
82
+ phase = "done";
83
+ controller.close();
84
+ return;
85
+ }
86
+
87
+ controller.enqueue(value);
88
+ } catch (error) {
89
+ controller.error(error);
90
+ }
91
+ },
92
+
93
+ cancel() {
94
+ void contentReader?.cancel();
95
+ },
96
+ });
97
+ }
98
+
99
+ /** Generates resource hints for critical assets. */
100
+ export function generateResourceHints(options: {
101
+ scripts?: string[];
102
+ stylesheets?: string[];
103
+ preconnect?: string[];
104
+ dnsPrefetch?: string[];
105
+ }) {
106
+ const hints: string[] = [];
107
+
108
+ for (const origin of options.preconnect ?? []) {
109
+ hints.push(/* html */ `<link rel="preconnect" href="${origin}" crossorigin>`);
110
+ }
111
+
112
+ for (const origin of options.dnsPrefetch ?? []) {
113
+ hints.push(/* html */ `<link rel="dns-prefetch" href="${origin}">`);
114
+ }
115
+
116
+ for (const href of options.stylesheets ?? []) {
117
+ hints.push(/* html */ `<link rel="preload" href="${href}" as="style">`);
118
+ }
119
+
120
+ for (const href of options.scripts ?? []) {
121
+ hints.push(/* html */ `<link rel="modulepreload" href="${href}">`);
122
+ }
123
+
124
+ return hints.join("\n");
125
+ }
@@ -0,0 +1,83 @@
1
+ /** Resource to hint. */
2
+ export interface EarlyHint {
3
+ href: string;
4
+ rel: "preload" | "preconnect" | "modulepreload" | "dns-prefetch";
5
+ as?: "script" | "style" | "font" | "image" | "fetch";
6
+ crossorigin?: "anonymous" | "use-credentials";
7
+ type?: string;
8
+ }
9
+
10
+ /** Generates Link header value for 103 Early Hints. */
11
+ export function generateEarlyHintsHeader(hints: EarlyHint[]) {
12
+ return hints
13
+ .map((hint) => {
14
+ const parts = [`<${hint.href}>`, `rel=${hint.rel}`];
15
+
16
+ if (hint.as) parts.push(`as=${hint.as}`);
17
+ if (hint.crossorigin) parts.push(`crossorigin=${hint.crossorigin}`);
18
+ if (hint.type) parts.push(`type="${hint.type}"`);
19
+
20
+ return parts.join("; ");
21
+ })
22
+ .join(", ");
23
+ }
24
+
25
+ /** Collects early hints for a route. */
26
+ export function collectEarlyHints(options: {
27
+ scriptPath?: string;
28
+ stylesheets?: string[];
29
+ fonts?: string[];
30
+ preconnectOrigins?: string[];
31
+ }) {
32
+ const hints: EarlyHint[] = [];
33
+
34
+ for (const origin of options.preconnectOrigins ?? []) {
35
+ hints.push({ href: origin, rel: "preconnect", crossorigin: "anonymous" });
36
+ }
37
+
38
+ for (const font of options.fonts ?? []) {
39
+ hints.push({
40
+ href: font,
41
+ rel: "preload",
42
+ as: "font",
43
+ crossorigin: "anonymous",
44
+ type: font.endsWith(". woff2") ? "font/woff2" : undefined,
45
+ });
46
+ }
47
+
48
+ for (const stylesheet of options.stylesheets ?? []) {
49
+ hints.push({ href: stylesheet, rel: "preload", as: "style" });
50
+ }
51
+
52
+ if (options.scriptPath) {
53
+ hints.push({ href: options.scriptPath, rel: "modulepreload" });
54
+ }
55
+
56
+ return hints;
57
+ }
58
+
59
+ /** Enhanced worker handler with 103 Early Hints support. */
60
+ export async function handleWithEarlyHints(
61
+ request: Request,
62
+ handler: (request: Request) => Promise<Response>,
63
+ getHints: (url: URL) => EarlyHint[],
64
+ ) {
65
+ const url = new URL(request.url);
66
+ const hints = getHints(url);
67
+
68
+ const response = await handler(request);
69
+
70
+ if (hints.length > 0) {
71
+ const linkHeader = generateEarlyHintsHeader(hints);
72
+ const newHeaders = new Headers(response.headers);
73
+ newHeaders.set("Link", linkHeader);
74
+
75
+ return new Response(response.body, {
76
+ status: response.status,
77
+ statusText: response.statusText,
78
+ headers: newHeaders,
79
+ });
80
+ }
81
+
82
+ return response;
83
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** Fetch retry options. */
2
+ export interface FetchRetryOptions {
3
+ /** Max retry attempts. @default 3 */
4
+ maxRetries?: number;
5
+ /** Base delay in ms between retries. @default 1000 */
6
+ baseDelay?: number;
7
+ /** Status codes to retry on. @default 5xx errors */
8
+ retryOnStatus?: (status: number) => boolean;
9
+ }
10
+
11
+ /** Fetch with exponential backoff retry for transient failures. */
12
+ export async function fetchWithRetry(
13
+ input: RequestInfo | URL,
14
+ init?: RequestInit,
15
+ options: FetchRetryOptions = {},
16
+ ) {
17
+ const { maxRetries = 3, baseDelay = 1000, retryOnStatus = (status) => status >= 500 } = options;
18
+
19
+ let lastError: Error | null = null;
20
+
21
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
22
+ try {
23
+ const response = await fetch(input, init);
24
+
25
+ // Don't retry client errors (4xx), only server errors (5xx)
26
+ if (response.ok || !retryOnStatus(response.status)) {
27
+ return response;
28
+ }
29
+
30
+ lastError = new Error(`HTTP ${response.status}`);
31
+ } catch (error) {
32
+ // Network errors are retryable
33
+ lastError = error instanceof Error ? error : new Error(String(error));
34
+ }
35
+
36
+ // Don't wait after the last attempt
37
+ if (attempt < maxRetries) {
38
+ const delay = baseDelay * Math.pow(2, attempt);
39
+ await new Promise((resolve) => setTimeout(resolve, delay));
40
+ }
41
+ }
42
+
43
+ throw lastError ?? new Error("Fetch failed after retries");
44
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { access } from "node:fs/promises";
2
+
3
+ /** Node.js file system helpers. */
4
+ export async function exists(path: string) {
5
+ try {
6
+ await access(path);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }