@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,65 @@
1
+ import diff from "./diff-dom-streaming.ts";
2
+ import type { HeadTag } from "./head.ts";
3
+ import { decode } from "turbo-stream";
4
+
5
+ export interface PatchMeta {
6
+ outlet?: string;
7
+ head?: HeadTag[];
8
+ htmlAttrs?: Record<string, string>;
9
+ bodyAttrs?: Record<string, string>;
10
+ }
11
+
12
+ /** Decoded patch payload from turbo-stream. */
13
+ interface PatchPayload {
14
+ meta: PatchMeta;
15
+ html: AsyncIterable<string>;
16
+ }
17
+
18
+ export interface ApplyPatchStreamOptions {
19
+ useTransition: boolean;
20
+ applyMeta: (meta: PatchMeta) => void;
21
+ onChunkProcessed?: () => void;
22
+ }
23
+
24
+ export async function applyPatchStream(response: Response, options: ApplyPatchStreamOptions) {
25
+ if (!response.body) {
26
+ throw new Error("Patch response is missing body");
27
+ }
28
+
29
+ const encoder = new TextEncoder();
30
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
31
+
32
+ const htmlStream = new ReadableStream<Uint8Array>({
33
+ start(c) {
34
+ controller = c;
35
+ },
36
+ });
37
+
38
+ // Decode turbo-stream payload: { meta, html: AsyncIterable<string> }
39
+ const stringStream = response.body.pipeThrough(new TextDecoderStream());
40
+ const payload = (await decode(stringStream)) as PatchPayload;
41
+
42
+ // Apply meta immediately
43
+ options.applyMeta(payload.meta);
44
+
45
+ // Consume html async iterable, forwarding chunks to diff-dom-streaming
46
+ const consumeHtml = (async () => {
47
+ try {
48
+ for await (const chunk of payload.html) {
49
+ controller?.enqueue(encoder.encode(chunk));
50
+ }
51
+ } catch (err) {
52
+ controller?.error(err as Error);
53
+ throw err;
54
+ } finally {
55
+ controller?.close();
56
+ }
57
+ })();
58
+
59
+ await diff(document, htmlStream, {
60
+ transition: options.useTransition,
61
+ syncMutations: !options.useTransition,
62
+ onChunkProcessed: options.onChunkProcessed,
63
+ });
64
+ await consumeHtml;
65
+ }
package/src/router.ts ADDED
@@ -0,0 +1,535 @@
1
+ import { signal, computed, effect, type ReadonlySignal } from "@preact/signals";
2
+ import { resetHeadContext, applyHeadTags, type HeadTag } from "./head.ts";
3
+ import { setNavigationMode } from "./hydration.ts";
4
+ import type { RouteManifestEntry, RoutesManifest } from "./manifest.ts";
5
+ export type { RouteManifestEntry, RoutesManifest } from "./manifest.ts";
6
+ import { dedupeDeferredScripts, handleDeferredHydrationNode } from "./router-deferred.ts";
7
+ import { fetchWithRetry } from "./fetch.ts";
8
+ import { applyPatchStream } from "./router-stream.ts";
9
+
10
+ /** Internal route representation. */
11
+ interface Route {
12
+ pattern: URLPattern;
13
+ entry: RouteManifestEntry;
14
+ }
15
+
16
+ /** Route match result. */
17
+ export interface RouteMatch {
18
+ entry: RouteManifestEntry;
19
+ params: Record<string, string>;
20
+ url: URL;
21
+ }
22
+
23
+ /** Navigation options. */
24
+ export interface NavigateOptions {
25
+ replace?: boolean;
26
+ state?: unknown;
27
+ skipTransition?: boolean;
28
+ }
29
+
30
+ /** Router configuration. */
31
+ export interface RouterConfig {
32
+ base?: string;
33
+ viewTransitions?: boolean;
34
+ scrollBehavior?: "auto" | "smooth" | "instant" | false;
35
+ onNotFound?: (url: URL) => void;
36
+ onNavigate?: (match: RouteMatch) => void;
37
+ onError?: (error: Error, url: URL) => void;
38
+ }
39
+
40
+ /** Subscription callback for route changes. */
41
+ export type RouteSubscriber = (match: RouteMatch | null) => void;
42
+
43
+ /** Checks if View Transitions API is supported. */
44
+ export function supportsViewTransitions() {
45
+ return typeof document !== "undefined" && "startViewTransition" in document;
46
+ }
47
+
48
+ /** Client-side SPA router using Navigation API and View Transitions. */
49
+ export class Router {
50
+ #routes: Route[] = [];
51
+ #config: Required<RouterConfig>;
52
+ #started = false;
53
+ #cleanupFns: (() => void)[] = [];
54
+ #inflightAbort: AbortController | null = null;
55
+
56
+ /** Current route match signal. */
57
+ readonly current = signal<RouteMatch | null>(null);
58
+
59
+ /** Current route params signal. */
60
+ readonly params: ReadonlySignal<Record<string, string>>;
61
+
62
+ /** Current pathname signal. */
63
+ readonly pathname: ReadonlySignal<string>;
64
+
65
+ constructor(manifest: RoutesManifest, config: RouterConfig = {}) {
66
+ const getMeta = <T extends string>(name: string) => {
67
+ if (typeof document === "undefined") return null;
68
+ const meta = document.querySelector(`meta[name="sf:${name}"]`);
69
+ return (meta?.getAttribute("content") as T) ?? null;
70
+ };
71
+
72
+ const metaBase = getMeta("base");
73
+ const metaViewTransitions = getMeta<"true" | "false">("view-transitions");
74
+ const metaScrollBehavior = getMeta<"auto" | "smooth" | "instant">("scroll-behavior");
75
+
76
+ this.#config = {
77
+ base: config.base ?? metaBase ?? manifest.base ?? "",
78
+ viewTransitions: config.viewTransitions ?? metaViewTransitions === "true",
79
+ scrollBehavior: config.scrollBehavior ?? metaScrollBehavior ?? "auto",
80
+ onNotFound: config.onNotFound ?? (() => {}),
81
+ onNavigate: config.onNavigate ?? (() => {}),
82
+ onError:
83
+ config.onError ??
84
+ ((error, url) => {
85
+ console.error(`[solarflare] Navigation error for ${url.href}:`, error);
86
+ }),
87
+ };
88
+
89
+ this.params = computed(() => this.current.value?.params ?? {});
90
+ this.pathname = computed(() => this.current.value?.url.pathname ?? "");
91
+ this.#loadManifest(manifest);
92
+ }
93
+
94
+ /** Loads routes from build-time manifest. */
95
+ #loadManifest(manifest: RoutesManifest) {
96
+ for (const entry of manifest.routes) {
97
+ if (entry.type !== "client") continue;
98
+
99
+ const pathname = this.#config.base + entry.pattern;
100
+ this.#routes.push({
101
+ pattern: new URLPattern({ pathname }),
102
+ entry,
103
+ });
104
+ }
105
+
106
+ this.#routes.sort((a, b) => {
107
+ const aStatic = (a.entry.pattern.match(/[^:*]+/g) || []).join("").length;
108
+ const bStatic = (b.entry.pattern.match(/[^:*]+/g) || []).join("").length;
109
+ return bStatic - aStatic;
110
+ });
111
+ }
112
+
113
+ /** Handles navigation errors. */
114
+ #handleError(error: Error, url: URL) {
115
+ this.#config.onError(error, url);
116
+
117
+ const app = document.querySelector("#app");
118
+ if (app) {
119
+ const escapeHtml = (str: string) =>
120
+ str.replace(
121
+ /[&<>"']/g,
122
+ (c) =>
123
+ ({
124
+ "&": "&amp;",
125
+ "<": "&lt;",
126
+ ">": "&gt;",
127
+ '"': "&quot;",
128
+ "'": "&#39;",
129
+ })[c] || c,
130
+ );
131
+
132
+ app.innerHTML = /* html */ `<div><h1>Error</h1><p>${escapeHtml(error.message)}</p></div>`;
133
+ }
134
+ }
135
+
136
+ /** Matches a URL against routes. */
137
+ match(url: URL) {
138
+ for (const { pattern, entry } of this.#routes) {
139
+ const result = pattern.exec(url);
140
+ if (result) {
141
+ const params: Record<string, string> = {};
142
+ for (const [key, value] of Object.entries(result.pathname.groups)) {
143
+ if (value != null) params[key] = value as string;
144
+ }
145
+ return { entry, params, url };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** Navigates to a URL. */
152
+ async navigate(to: string | URL, options: NavigateOptions = {}) {
153
+ const url = typeof to === "string" ? new URL(to, location.origin) : to;
154
+
155
+ const nav = (window as any).navigation;
156
+ if (nav) {
157
+ await nav.navigate(url.href, {
158
+ history: options.replace ? "replace" : "auto",
159
+ state: options.state,
160
+ });
161
+ }
162
+ }
163
+
164
+ /** Executes navigation with optional view transition. */
165
+ async #executeNavigation(url: URL, match: RouteMatch | null) {
166
+ try {
167
+ if (match) {
168
+ await this.#loadRoute(match, url);
169
+ this.current.value = match;
170
+ this.#config.onNavigate(match);
171
+ } else {
172
+ this.current.value = null;
173
+ this.#config.onNotFound(url);
174
+ }
175
+ // Dispatch navigation event for components to re-extract deferred data
176
+ window.dispatchEvent(new CustomEvent("sf:navigate", { detail: { url, match } }));
177
+ } catch (error) {
178
+ this.#handleError(error instanceof Error ? error : new Error(String(error)), url);
179
+ }
180
+ }
181
+
182
+ /** Loads route assets and swaps page content. */
183
+ async #loadRoute(match: RouteMatch, url: URL) {
184
+ const { entry } = match;
185
+
186
+ // Preload the route chunk *before* DOM diffing so any custom elements for the
187
+ // incoming HTML are already defined when inserted.
188
+ // This avoids edge-cases where upgraded callbacks/hydration don't run reliably
189
+ // when elements are inserted first and defined later.
190
+ if (entry.chunk) {
191
+ const absoluteChunk = new URL(entry.chunk, location.origin).href;
192
+ await import(absoluteChunk);
193
+ }
194
+
195
+ if (this.#inflightAbort) {
196
+ this.#inflightAbort.abort();
197
+ }
198
+ const abortController = new AbortController();
199
+ this.#inflightAbort = abortController;
200
+
201
+ const patchUrl = new URL("/_sf/patch", location.origin);
202
+
203
+ const response = await fetchWithRetry(
204
+ patchUrl.href,
205
+ {
206
+ method: "POST",
207
+ headers: {
208
+ Accept: "application/x-turbo-stream",
209
+ "Content-Type": "application/json",
210
+ },
211
+ body: JSON.stringify({ url: url.pathname + url.search + url.hash, outlet: "#app" }),
212
+ signal: abortController.signal,
213
+ },
214
+ { maxRetries: 0 },
215
+ );
216
+
217
+ if (!response.ok || !response.body) {
218
+ throw new Error(`Failed to fetch patch for ${url.href}: ${response.status}`);
219
+ }
220
+
221
+ // During client-side navigation we apply streamed HTML via diff-dom-streaming.
222
+ // Track processed hydration scripts and trigger hydration as they're inserted.
223
+ const processedScripts = new Set<string>();
224
+
225
+ // Capture the current route host before diffing so we can detect tag changes
226
+ // and whether the host element was actually replaced.
227
+ const previousHost = document.querySelector("#app > *") as HTMLElement | null;
228
+ const previousTag = previousHost?.tagName?.toLowerCase();
229
+
230
+ // Use MutationObserver to detect when deferred hydration scripts are inserted
231
+ // into the real DOM and trigger hydration immediately for progressive streaming.
232
+ let observer: MutationObserver | null = null;
233
+ if (typeof MutationObserver !== "undefined") {
234
+ observer = new MutationObserver((mutations) => {
235
+ for (const mutation of mutations) {
236
+ for (const node of mutation.addedNodes) {
237
+ if (node.nodeType !== 1) continue;
238
+ handleDeferredHydrationNode(entry.tag, processedScripts, node as Element);
239
+
240
+ // Also scan subtree for scripts as diff-dom-streaming may insert trees
241
+ if (node.nodeName !== "SCRIPT") {
242
+ const el = node as Element;
243
+ const scripts = el.getElementsByTagName("script");
244
+ for (const script of scripts) {
245
+ handleDeferredHydrationNode(entry.tag, processedScripts, script);
246
+ }
247
+ }
248
+ }
249
+ }
250
+ });
251
+ observer.observe(document.documentElement, {
252
+ childList: true,
253
+ subtree: true,
254
+ });
255
+ }
256
+
257
+ // Enter navigation mode - tells hydration coordinator to preserve data island scripts
258
+ // during diff, so they can be used by the cloned element after replacement.
259
+ setNavigationMode(true);
260
+
261
+ // Use view transitions for visual animation if supported and enabled.
262
+ // Use syncMutations to apply DOM changes immediately during streaming for progressive
263
+ // deferred content hydration. This ensures each streamed chunk is visible immediately.
264
+ const useTransition = this.#config.viewTransitions && supportsViewTransitions();
265
+ let didScroll = false;
266
+
267
+ const applyAttrs = (el: HTMLElement, attrs?: Record<string, string>) => {
268
+ if (!attrs) return;
269
+ for (const [key, value] of Object.entries(attrs)) {
270
+ if (value === "") {
271
+ el.setAttribute(key, "");
272
+ } else {
273
+ el.setAttribute(key, value);
274
+ }
275
+ }
276
+ };
277
+
278
+ const applyMeta = (meta: {
279
+ head?: HeadTag[];
280
+ htmlAttrs?: Record<string, string>;
281
+ bodyAttrs?: Record<string, string>;
282
+ }) => {
283
+ if (meta.head?.length) {
284
+ applyHeadTags(meta.head);
285
+ }
286
+ if (typeof document !== "undefined") {
287
+ applyAttrs(document.documentElement, meta.htmlAttrs);
288
+ applyAttrs(document.body, meta.bodyAttrs);
289
+ }
290
+ };
291
+
292
+ try {
293
+ await applyPatchStream(response, {
294
+ useTransition,
295
+ applyMeta,
296
+ onChunkProcessed: () => {
297
+ if (didScroll) return;
298
+ didScroll = true;
299
+ // Scroll after the first chunk has been applied so we don't
300
+ // jump to top when deferred content resolves later.
301
+ requestAnimationFrame(() => {
302
+ requestAnimationFrame(() => this.#handleScroll(url));
303
+ });
304
+ },
305
+ });
306
+ } catch (diffError) {
307
+ abortController.abort();
308
+ // diff-dom-streaming can fail with "insertBefore" errors when the DOM was mutated
309
+ // by external factors (Preact custom elements, HMR, extensions). Fallback to a
310
+ // full navigation which lets the browser handle parsing.
311
+ observer?.disconnect();
312
+ setNavigationMode(false);
313
+ console.warn("[solarflare] DOM diff failed, falling back to full navigation:", diffError);
314
+ location.href = url.href;
315
+ return;
316
+ } finally {
317
+ observer?.disconnect();
318
+ if (this.#inflightAbort === abortController) {
319
+ this.#inflightAbort = null;
320
+ }
321
+ }
322
+
323
+ // Fix for broken interactivity + flicker:
324
+ // If navigating to a different component tag, replace the host immediately
325
+ // to ensure a fresh hydration without stale properties from previous component.
326
+ // Doing this BEFORE the settlement delay prevents visual flicker (replacement happens in same frame).
327
+ const host = document.querySelector(entry.tag) as HTMLElement | null;
328
+ const sameTag = previousTag && previousTag === entry.tag;
329
+
330
+ if (host && previousTag && !sameTag && previousHost && host === previousHost) {
331
+ const replacement = host.cloneNode(true) as HTMLElement;
332
+ host.replaceWith(replacement);
333
+ }
334
+
335
+ // Wait for any pending DOM work to settle before element replacement.
336
+ // With view transitions, wait for ALL transitions to complete (not just the last one).
337
+ // Without them, mutations are flushed synchronously via FLUSH_SYNC, but wait two frames
338
+ // to ensure custom elements have mounted.
339
+ if (useTransition) {
340
+ const transitions: ViewTransition[] | undefined = (window as any).lastDiffTransitions;
341
+ if (transitions?.length) {
342
+ await Promise.all(transitions.map((t) => t.finished.catch(() => {})));
343
+ // Clear for next navigation
344
+ (window as any).lastDiffTransitions = [];
345
+ }
346
+ } else {
347
+ // First frame: batched mutations apply
348
+ await new Promise((r) => requestAnimationFrame(r));
349
+ // Second frame: custom element connectedCallbacks complete
350
+ await new Promise((r) => requestAnimationFrame(r));
351
+ }
352
+
353
+ // IMPORTANT: diff-dom-streaming may patch inside an existing custom element subtree,
354
+ // which can desync event delegation/handlers. When the route tag is the same,
355
+ // trigger a rerender to re-bind events without losing local state (e.g. counters).
356
+ if (sameTag) {
357
+ const currentHost = document.querySelector(entry.tag) as HTMLElement;
358
+ if (currentHost) {
359
+ currentHost.dispatchEvent(new CustomEvent("sf:rerender"));
360
+ await new Promise((r) => requestAnimationFrame(r));
361
+ }
362
+ }
363
+
364
+ // Clean up any duplicate deferred islands or hydrate scripts introduced by
365
+ // streaming diffs while navigation mode preserved previous scripts.
366
+ dedupeDeferredScripts(entry.tag);
367
+
368
+ // Exit navigation mode - allow normal script cleanup going forward
369
+ setNavigationMode(false);
370
+
371
+ // Reset head context after navigation - the new HTML has fresh head tags
372
+ // and any new useHead calls from hydrated components will be fresh
373
+ resetHeadContext();
374
+
375
+ if (entry.styles?.length) {
376
+ const fragment = document.createDocumentFragment();
377
+ let hasNew = false;
378
+ for (const href of entry.styles) {
379
+ const absoluteHref = new URL(href, location.origin).href;
380
+ if (!document.querySelector(`link[href="${absoluteHref}"], link[href="${href}"]`)) {
381
+ const link = document.createElement("link");
382
+ link.rel = "stylesheet";
383
+ link.href = absoluteHref;
384
+ fragment.appendChild(link);
385
+ hasNew = true;
386
+ }
387
+ }
388
+ if (hasNew) {
389
+ document.head.appendChild(fragment);
390
+ }
391
+ }
392
+ }
393
+
394
+ /** Handles scroll restoration. */
395
+ #handleScroll(url: URL) {
396
+ const behavior = this.#config.scrollBehavior;
397
+ if (behavior === false) return;
398
+
399
+ const scrollBehavior: ScrollBehavior = behavior === "instant" ? "auto" : behavior;
400
+
401
+ if (url.hash) {
402
+ const target = document.querySelector(url.hash);
403
+ if (target) {
404
+ target.scrollIntoView({ behavior: scrollBehavior });
405
+ return;
406
+ }
407
+ }
408
+
409
+ scrollTo({ top: 0, left: 0, behavior: scrollBehavior });
410
+ }
411
+
412
+ /** Starts intercepting navigation. */
413
+ start() {
414
+ if (this.#started) return this;
415
+
416
+ this.#setupNavigationAPI();
417
+
418
+ const url = new URL(location.href);
419
+ const match = this.match(url);
420
+ if (match) {
421
+ this.current.value = match;
422
+ }
423
+
424
+ this.#started = true;
425
+ return this;
426
+ }
427
+
428
+ /** Stops the router and cleans up listeners. */
429
+ stop() {
430
+ for (const cleanup of this.#cleanupFns) {
431
+ cleanup();
432
+ }
433
+ this.#cleanupFns = [];
434
+ this.#started = false;
435
+ return this;
436
+ }
437
+
438
+ /** Sets up Navigation API interception. */
439
+ #setupNavigationAPI() {
440
+ const nav = (window as any).navigation;
441
+ if (!nav) return; // Navigation API not supported (e.g., Safari)
442
+
443
+ const handler = (event: any) => {
444
+ if (!event.canIntercept || event.downloadRequest) return;
445
+
446
+ const url = new URL(event.destination.url);
447
+ if (url.origin !== location.origin) return;
448
+
449
+ const match = this.match(url);
450
+ if (!match) return;
451
+
452
+ event.intercept({
453
+ scroll: "manual",
454
+ handler: () => this.#executeNavigation(url, match),
455
+ });
456
+ };
457
+
458
+ nav.addEventListener("navigate", handler);
459
+ this.#cleanupFns.push(() => nav.removeEventListener("navigate", handler));
460
+ }
461
+
462
+ /** Subscribes to route changes. Returns unsubscribe function. */
463
+ subscribe(callback: RouteSubscriber) {
464
+ return effect(() => {
465
+ callback(this.current.value);
466
+ });
467
+ }
468
+
469
+ back() {
470
+ history.back();
471
+ }
472
+
473
+ forward() {
474
+ history.forward();
475
+ }
476
+
477
+ go(delta: number) {
478
+ history.go(delta);
479
+ }
480
+
481
+ /** Checks if a path matches the current route. */
482
+ isActive(path: string, exact = false) {
483
+ const match = this.current.value;
484
+ if (!match) {
485
+ if (typeof location === "undefined") return false;
486
+ const currentPath = location.pathname;
487
+ return exact ? currentPath === path : currentPath.startsWith(path);
488
+ }
489
+
490
+ const currentPath = match.url.pathname;
491
+ return exact ? currentPath === path : currentPath.startsWith(path);
492
+ }
493
+
494
+ /** Returns a computed signal for reactive isActive check. */
495
+ isActiveSignal(path: string, exact = false) {
496
+ return computed(() => {
497
+ const match = this.current.value;
498
+ if (!match) return false;
499
+ const currentPath = match.url.pathname;
500
+ return exact ? currentPath === path : currentPath.startsWith(path);
501
+ });
502
+ }
503
+ }
504
+
505
+ /** Creates a router from a build-time routes manifest. */
506
+ export function createRouter(manifest: RoutesManifest, config?: RouterConfig) {
507
+ return new Router(manifest, config);
508
+ }
509
+
510
+ let globalRouter: Router | null = null;
511
+
512
+ /** Gets the global router instance (throws if not initialized). */
513
+ export function getRouter() {
514
+ if (!globalRouter) {
515
+ throw new Error("[solarflare] Router not initialized. Call initRouter() first.");
516
+ }
517
+ return globalRouter;
518
+ }
519
+
520
+ /** Initializes the global router instance. */
521
+ export function initRouter(manifest: RoutesManifest, config?: RouterConfig) {
522
+ if (globalRouter) return globalRouter;
523
+ globalRouter = createRouter(manifest, config);
524
+ return globalRouter;
525
+ }
526
+
527
+ /** Navigates using the global router. */
528
+ export function navigate(to: string | URL, options?: NavigateOptions) {
529
+ return getRouter().navigate(to, options);
530
+ }
531
+
532
+ /** Checks if path is active using global router. */
533
+ export function isActive(path: string, exact = false) {
534
+ return getRouter().isActive(path, exact);
535
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { HeadContext } from "./head.ts";
2
+
3
+ const NAMESPACE = "__solarflare__" as const;
4
+
5
+ interface SolarflareRuntime {
6
+ /** Preloaded stylesheets from SSR. */
7
+ preloadedStyles?: Map<string, CSSStyleSheet>;
8
+ /** Head context for deduplication. */
9
+ headContext?: HeadContext;
10
+ /** HMR data for hot module replacement. */
11
+ hmrData?: Record<string, unknown>;
12
+ }
13
+
14
+ type GlobalWithSolarflare = typeof globalThis & {
15
+ [NAMESPACE]?: SolarflareRuntime;
16
+ };
17
+
18
+ /** Gets the runtime context, creating it if needed. */
19
+ export function getRuntime() {
20
+ const g = globalThis as GlobalWithSolarflare;
21
+ return (g[NAMESPACE] ??= {});
22
+ }
23
+
24
+ /** Gets the runtime if it exists (no creation). */
25
+ export function peekRuntime() {
26
+ return (globalThis as GlobalWithSolarflare)[NAMESPACE];
27
+ }
28
+
29
+ /** Clears the runtime (useful for testing). */
30
+ export function clearRuntime() {
31
+ delete (globalThis as GlobalWithSolarflare)[NAMESPACE];
32
+ }
@@ -0,0 +1,38 @@
1
+ import { encode, decode } from "turbo-stream";
2
+
3
+ /** Serialize data to a string. */
4
+ export async function serializeToString(data: unknown) {
5
+ const stream = encode(data);
6
+ const reader = stream.getReader();
7
+ const chunks: string[] = [];
8
+
9
+ while (true) {
10
+ const { done, value } = await reader.read();
11
+ if (done) break;
12
+ chunks.push(value);
13
+ }
14
+
15
+ return chunks.join("");
16
+ }
17
+
18
+ /** Parse serialized data from a string. */
19
+ export async function parseFromString<T>(serialized: string) {
20
+ const stream = new ReadableStream<string>({
21
+ start(controller) {
22
+ controller.enqueue(serialized);
23
+ controller.close();
24
+ },
25
+ });
26
+ return (await decode(stream)) as T;
27
+ }
28
+
29
+ /**
30
+ * Safely stringify JSON for embedding in HTML script tags.
31
+ * Escapes <, >, and & to prevent XSS.
32
+ * Returns "undefined" when JSON.stringify returns undefined.
33
+ */
34
+ export function escapeJsonForHtml(obj: unknown) {
35
+ const json = JSON.stringify(obj);
36
+ if (json === undefined) return "undefined";
37
+ return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
38
+ }