@pyreon/server 0.14.0 → 0.16.0

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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"client.js","children":[{"name":"src/client.ts","uid":"9000fe03-1"}]}],"isRoot":true},"nodeParts":{"9000fe03-1":{"renderedLength":4420,"gzipLength":1683,"brotliLength":0,"metaUid":"9000fe03-0"}},"nodeMetas":{"9000fe03-0":{"id":"/src/client.ts","moduleParts":{"client.js":"9000fe03-1"},"imported":[{"uid":"9000fe03-2"},{"uid":"9000fe03-3"},{"uid":"9000fe03-4"}],"importedBy":[],"isEntry":true},"9000fe03-2":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"9000fe03-0"}]},"9000fe03-3":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"9000fe03-0"}]},"9000fe03-4":{"id":"@pyreon/runtime-dom","moduleParts":{},"imported":[],"importedBy":[{"uid":"9000fe03-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"client.js","children":[{"name":"src/client.ts","uid":"edf0a820-1"}]}],"isRoot":true},"nodeParts":{"edf0a820-1":{"renderedLength":11827,"gzipLength":3912,"brotliLength":0,"metaUid":"edf0a820-0"}},"nodeMetas":{"edf0a820-0":{"id":"/src/client.ts","moduleParts":{"client.js":"edf0a820-1"},"imported":[{"uid":"edf0a820-2"},{"uid":"edf0a820-3"},{"uid":"edf0a820-4"}],"importedBy":[],"isEntry":true},"edf0a820-2":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"edf0a820-0"}]},"edf0a820-3":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"edf0a820-0"}]},"edf0a820-4":{"id":"@pyreon/runtime-dom","moduleParts":{},"imported":[],"importedBy":[{"uid":"edf0a820-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"home/runner/work/pyreon/pyreon/packages/core/head/lib/ssr.js","uid":"e7d677ae-1"},{"name":"src","children":[{"uid":"e7d677ae-3","name":"html.ts"},{"uid":"e7d677ae-5","name":"middleware.ts"},{"uid":"e7d677ae-7","name":"handler.ts"},{"uid":"e7d677ae-9","name":"island.ts"},{"uid":"e7d677ae-11","name":"ssg.ts"},{"uid":"e7d677ae-13","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"e7d677ae-1":{"renderedLength":2713,"gzipLength":1078,"brotliLength":0,"metaUid":"e7d677ae-0"},"e7d677ae-3":{"renderedLength":2790,"gzipLength":1162,"brotliLength":0,"metaUid":"e7d677ae-2"},"e7d677ae-5":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"e7d677ae-4"},"e7d677ae-7":{"renderedLength":3104,"gzipLength":1337,"brotliLength":0,"metaUid":"e7d677ae-6"},"e7d677ae-9":{"renderedLength":1401,"gzipLength":664,"brotliLength":0,"metaUid":"e7d677ae-8"},"e7d677ae-11":{"renderedLength":2806,"gzipLength":1214,"brotliLength":0,"metaUid":"e7d677ae-10"},"e7d677ae-13":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"e7d677ae-12"}},"nodeMetas":{"e7d677ae-0":{"id":"/home/runner/work/pyreon/pyreon/packages/core/head/lib/ssr.js","moduleParts":{"index.js":"e7d677ae-1"},"imported":[{"uid":"e7d677ae-14"},{"uid":"e7d677ae-16"}],"importedBy":[{"uid":"e7d677ae-6"}]},"e7d677ae-2":{"id":"/src/html.ts","moduleParts":{"index.js":"e7d677ae-3"},"imported":[],"importedBy":[{"uid":"e7d677ae-12"},{"uid":"e7d677ae-6"}]},"e7d677ae-4":{"id":"/src/middleware.ts","moduleParts":{"index.js":"e7d677ae-5"},"imported":[{"uid":"e7d677ae-14"}],"importedBy":[{"uid":"e7d677ae-12"},{"uid":"e7d677ae-6"}]},"e7d677ae-6":{"id":"/src/handler.ts","moduleParts":{"index.js":"e7d677ae-7"},"imported":[{"uid":"e7d677ae-14"},{"uid":"e7d677ae-0"},{"uid":"e7d677ae-15"},{"uid":"e7d677ae-16"},{"uid":"e7d677ae-2"},{"uid":"e7d677ae-4"}],"importedBy":[{"uid":"e7d677ae-12"}]},"e7d677ae-8":{"id":"/src/island.ts","moduleParts":{"index.js":"e7d677ae-9"},"imported":[{"uid":"e7d677ae-14"}],"importedBy":[{"uid":"e7d677ae-12"}]},"e7d677ae-10":{"id":"/src/ssg.ts","moduleParts":{"index.js":"e7d677ae-11"},"imported":[{"uid":"e7d677ae-17"},{"uid":"e7d677ae-18"}],"importedBy":[{"uid":"e7d677ae-12"}]},"e7d677ae-12":{"id":"/src/index.ts","moduleParts":{"index.js":"e7d677ae-13"},"imported":[{"uid":"e7d677ae-6"},{"uid":"e7d677ae-2"},{"uid":"e7d677ae-8"},{"uid":"e7d677ae-4"},{"uid":"e7d677ae-10"}],"importedBy":[],"isEntry":true},"e7d677ae-14":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-6"},{"uid":"e7d677ae-8"},{"uid":"e7d677ae-4"},{"uid":"e7d677ae-0"}]},"e7d677ae-15":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-6"}]},"e7d677ae-16":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-6"},{"uid":"e7d677ae-0"}]},"e7d677ae-17":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-10"}]},"e7d677ae-18":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-10"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"22c74963-1","name":"html.ts"},{"uid":"22c74963-3","name":"middleware.ts"},{"uid":"22c74963-5","name":"handler.ts"},{"uid":"22c74963-7","name":"island.ts"},{"uid":"22c74963-9","name":"ssg.ts"},{"uid":"22c74963-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"22c74963-1":{"renderedLength":2752,"gzipLength":1146,"brotliLength":0,"metaUid":"22c74963-0"},"22c74963-3":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"22c74963-2"},"22c74963-5":{"renderedLength":3356,"gzipLength":1446,"brotliLength":0,"metaUid":"22c74963-4"},"22c74963-7":{"renderedLength":2957,"gzipLength":1377,"brotliLength":0,"metaUid":"22c74963-6"},"22c74963-9":{"renderedLength":2806,"gzipLength":1214,"brotliLength":0,"metaUid":"22c74963-8"},"22c74963-11":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"22c74963-10"}},"nodeMetas":{"22c74963-0":{"id":"/src/html.ts","moduleParts":{"index.js":"22c74963-1"},"imported":[{"uid":"22c74963-14"}],"importedBy":[{"uid":"22c74963-10"},{"uid":"22c74963-4"}]},"22c74963-2":{"id":"/src/middleware.ts","moduleParts":{"index.js":"22c74963-3"},"imported":[{"uid":"22c74963-12"}],"importedBy":[{"uid":"22c74963-10"},{"uid":"22c74963-4"}]},"22c74963-4":{"id":"/src/handler.ts","moduleParts":{"index.js":"22c74963-5"},"imported":[{"uid":"22c74963-12"},{"uid":"22c74963-13"},{"uid":"22c74963-14"},{"uid":"22c74963-15"},{"uid":"22c74963-0"},{"uid":"22c74963-2"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-6":{"id":"/src/island.ts","moduleParts":{"index.js":"22c74963-7"},"imported":[{"uid":"22c74963-12"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-8":{"id":"/src/ssg.ts","moduleParts":{"index.js":"22c74963-9"},"imported":[{"uid":"22c74963-16"},{"uid":"22c74963-17"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-10":{"id":"/src/index.ts","moduleParts":{"index.js":"22c74963-11"},"imported":[{"uid":"22c74963-4"},{"uid":"22c74963-0"},{"uid":"22c74963-6"},{"uid":"22c74963-2"},{"uid":"22c74963-8"}],"importedBy":[],"isEntry":true},"22c74963-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"},{"uid":"22c74963-6"},{"uid":"22c74963-2"}]},"22c74963-13":{"id":"@pyreon/head/ssr","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"}]},"22c74963-14":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"},{"uid":"22c74963-0"}]},"22c74963-15":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"}]},"22c74963-16":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-8"}]},"22c74963-17":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/client.js CHANGED
@@ -3,6 +3,8 @@ import { RouterProvider, createRouter, hydrateLoaderData } from "@pyreon/router"
3
3
  import { hydrateRoot, mount } from "@pyreon/runtime-dom";
4
4
 
5
5
  //#region src/client.ts
6
+ const __DEV__ = process.env.NODE_ENV !== "production";
7
+ const _countSink = globalThis;
6
8
  /**
7
9
  * Hydrate a server-rendered Pyreon app on the client.
8
10
  *
@@ -14,6 +16,7 @@ import { hydrateRoot, mount } from "@pyreon/runtime-dom";
14
16
  * Returns a cleanup function that unmounts the app.
15
17
  */
16
18
  function startClient(options) {
19
+ /* v8 ignore next 3 */
17
20
  if (typeof document === "undefined") throw new Error("[Pyreon] startClient() can only be called in the browser.");
18
21
  const { App, routes, container = "#app" } = options;
19
22
  const el = typeof container === "string" ? document.querySelector(container) : container;
@@ -32,43 +35,138 @@ function startClient(options) {
32
35
  * Hydrate all `<pyreon-island>` elements on the page.
33
36
  *
34
37
  * Only loads JavaScript for components that are actually present in the HTML.
35
- * Respects hydration strategies (load, idle, visible, media, never).
38
+ * Respects hydration strategies (load, idle, visible, media, never). Returns
39
+ * a cleanup function that disconnects any pending observers/listeners.
40
+ *
41
+ * **`hydrate: 'never'` islands do NOT require a registry entry** — the whole
42
+ * point of the strategy is shipping zero client JS, so importing the loader
43
+ * (which would pull the component into the client bundle graph) defeats it.
44
+ * Such islands are silently skipped here without a `data-island-error` flag.
36
45
  *
37
46
  * @example
38
47
  * hydrateIslands({
39
48
  * Counter: () => import("./Counter"),
40
49
  * Search: () => import("./Search"),
50
+ * // No entry for `StaticBadge` even though it appears as a never-island
51
+ * // in the HTML — registering one would defeat the strategy.
41
52
  * })
42
53
  */
43
- /**
44
- * Hydrate all `<pyreon-island>` elements on the page.
45
- * Returns a cleanup function that disconnects any pending observers/listeners.
46
- */
47
54
  function hydrateIslands(registry) {
55
+ /* v8 ignore next */
48
56
  if (typeof document === "undefined") return () => {};
49
57
  const islands = document.querySelectorAll("pyreon-island");
50
58
  const cleanups = [];
51
59
  for (const el of islands) {
52
60
  const componentId = el.getAttribute("data-component");
53
61
  if (!componentId) continue;
62
+ if (el.parentElement?.closest("pyreon-island")) {
63
+ console.error(`[Pyreon] island "${componentId}" is nested inside another <pyreon-island>. Nested islands are not supported — the outer island's hydrateRoot replaces the inner element before its loader runs. Move the inner island out of the outer island's tree, or fold them into a single component.`);
64
+ el.setAttribute("data-island-error", "nested");
65
+ if (__DEV__) _countSink.__pyreon_count__?.("island.skipped.nested");
66
+ continue;
67
+ }
68
+ const strategy = el.getAttribute("data-hydrate") ?? "load";
69
+ if (strategy === "never") {
70
+ if (__DEV__) _countSink.__pyreon_count__?.("island.skipped.never");
71
+ continue;
72
+ }
54
73
  const loader = registry[componentId];
55
74
  if (!loader) {
56
75
  console.warn(`No loader registered for island "${componentId}"`);
76
+ el.setAttribute("data-island-error", "no-loader");
77
+ if (__DEV__) _countSink.__pyreon_count__?.("island.skipped.no-loader");
57
78
  continue;
58
79
  }
59
- const strategy = el.getAttribute("data-hydrate") ?? "load";
60
- const cleanup = scheduleHydration(el, loader, el.getAttribute("data-props") ?? "{}", strategy);
80
+ const propsJson = el.getAttribute("data-props") ?? "{}";
81
+ const prefetchCleanup = schedulePrefetch(el, loader, el.getAttribute("data-prefetch") ?? "none");
82
+ if (prefetchCleanup) cleanups.push(prefetchCleanup);
83
+ if (__DEV__) _countSink.__pyreon_count__?.("island.scheduled");
84
+ const cleanup = scheduleHydration(el, loader, propsJson, strategy);
61
85
  if (cleanup) cleanups.push(cleanup);
62
86
  }
63
87
  return () => {
64
88
  for (const fn of cleanups) fn();
65
89
  };
66
90
  }
91
+ function schedulePrefetch(el, loader, prefetch) {
92
+ if (prefetch === "none") return null;
93
+ /* v8 ignore next */
94
+ if (typeof window === "undefined") return null;
95
+ let cancelled = false;
96
+ const prime = () => {
97
+ if (cancelled) return;
98
+ if (__DEV__) _countSink.__pyreon_count__?.("island.prefetch");
99
+ loader().catch(() => {});
100
+ };
101
+ if (prefetch === "idle") {
102
+ if ("requestIdleCallback" in window) {
103
+ const id = requestIdleCallback(prime);
104
+ return () => {
105
+ cancelled = true;
106
+ cancelIdleCallback(id);
107
+ };
108
+ }
109
+ const id = setTimeout(prime, 200);
110
+ return () => {
111
+ cancelled = true;
112
+ clearTimeout(id);
113
+ };
114
+ }
115
+ if (!("IntersectionObserver" in window)) {
116
+ prime();
117
+ return null;
118
+ }
119
+ const observer = new IntersectionObserver((entries) => {
120
+ for (const entry of entries) if (entry.isIntersecting) {
121
+ observer.disconnect();
122
+ prime();
123
+ return;
124
+ }
125
+ }, { rootMargin: "200px" });
126
+ observer.observe(el);
127
+ return () => {
128
+ cancelled = true;
129
+ observer.disconnect();
130
+ };
131
+ }
132
+ /**
133
+ * Hydrate all `<pyreon-island>` elements using a registry auto-generated by
134
+ * `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default).
135
+ *
136
+ * Eliminates the manual sync between `island()` declarations in source and
137
+ * the client-side `hydrateIslands({ ... })` call — typo / forgotten entry /
138
+ * registry drift is the #1 author foot-gun for islands.
139
+ *
140
+ * The auto-registry omits `hydrate: 'never'` islands by design; their
141
+ * components stay out of the client bundle entirely. Other strategies
142
+ * resolve via the same dynamic-import paths their `island()` declaration
143
+ * specified.
144
+ *
145
+ * The user passes the virtual-module result. We don't import it inside
146
+ * `@pyreon/server/client` because Rolldown's static-import analysis runs
147
+ * before plugin resolveId hooks for workspace sources, and would fail to
148
+ * resolve the virtual specifier. Importing in the user's entry-client (where
149
+ * the plugin's resolveId fires natively) is the clean shape.
150
+ *
151
+ * @example
152
+ * // src/entry-client.ts
153
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
154
+ * import * as registry from 'virtual:pyreon/islands-registry'
155
+ * hydrateIslandsAuto(registry)
156
+ */
157
+ function hydrateIslandsAuto(registry) {
158
+ /* v8 ignore next */
159
+ if (typeof document === "undefined") return () => {};
160
+ if (!registry.__pyreonIslandsEnabled) throw new Error("[Pyreon] hydrateIslandsAuto() requires `pyreon({ islands: true })` in vite.config.ts (the default). The plugin emitted a stub registry because islands support was explicitly disabled. Either re-enable islands in the plugin, or use the manual hydrateIslands({ ... }) form.");
161
+ return hydrateIslands(registry.__pyreonIslandRegistry);
162
+ }
67
163
  function scheduleHydration(el, loader, propsJson, strategy) {
164
+ /* v8 ignore next */
68
165
  if (typeof window === "undefined") return null;
69
166
  let cancelled = false;
70
167
  const hydrate = () => {
71
- if (!cancelled) hydrateIsland(el, loader, propsJson);
168
+ if (cancelled) return Promise.resolve();
169
+ return hydrateIsland(el, loader, propsJson);
72
170
  };
73
171
  switch (strategy) {
74
172
  case "load":
@@ -89,7 +187,7 @@ function scheduleHydration(el, loader, propsJson, strategy) {
89
187
  };
90
188
  }
91
189
  case "visible": return observeVisibility(el, hydrate);
92
- case "never": return null;
190
+ case "interaction": return scheduleInteractionHydration(el, hydrate, DEFAULT_INTERACTION_EVENTS);
93
191
  default:
94
192
  if (strategy.startsWith("media(")) {
95
193
  const query = strategy.slice(6, -1);
@@ -110,10 +208,102 @@ function scheduleHydration(el, loader, propsJson, strategy) {
110
208
  mql.removeEventListener("change", onChange);
111
209
  };
112
210
  }
211
+ if (strategy.startsWith("interaction(")) {
212
+ const eventsStr = strategy.slice(12, -1).trim();
213
+ return scheduleInteractionHydration(el, hydrate, eventsStr ? eventsStr.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_INTERACTION_EVENTS);
214
+ }
113
215
  hydrate();
114
216
  return null;
115
217
  }
116
218
  }
219
+ /**
220
+ * Default events for the `interaction` strategy. Picked to cover the common
221
+ * "user reaches for it" surface: keyboard (`focus`), mouse (`pointerenter`,
222
+ * `click`), and touch (`touchstart`). First matching event triggers hydrate
223
+ * + removes ALL listeners (one-shot).
224
+ */
225
+ const DEFAULT_INTERACTION_EVENTS = [
226
+ "focus",
227
+ "click",
228
+ "pointerenter",
229
+ "touchstart"
230
+ ];
231
+ function scheduleInteractionHydration(el, hydrate, events) {
232
+ let hydrationStarted = false;
233
+ let hydrated = false;
234
+ let replayPath = null;
235
+ el.setAttribute("data-island-state", "awaiting-interaction");
236
+ const startHydration = () => {
237
+ if (hydrationStarted) return;
238
+ hydrationStarted = true;
239
+ el.setAttribute("data-island-state", "hydrating");
240
+ hydrate().then(() => {
241
+ hydrated = true;
242
+ el.removeAttribute("data-island-state");
243
+ for (const ev of events) el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS);
244
+ if (!replayPath) return;
245
+ const liveTarget = resolveReplayPath(el, replayPath);
246
+ if (liveTarget && liveTarget.isConnected) liveTarget.dispatchEvent(new MouseEvent("click", {
247
+ bubbles: true,
248
+ cancelable: true
249
+ }));
250
+ });
251
+ };
252
+ const dispatch = (event) => {
253
+ if (hydrated) return;
254
+ if (event.type === "click") {
255
+ event.stopImmediatePropagation();
256
+ event.preventDefault();
257
+ const target = event.target;
258
+ if (target) replayPath = captureReplayPath(el, target);
259
+ }
260
+ startHydration();
261
+ };
262
+ for (const ev of events) el.addEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS);
263
+ return () => {
264
+ if (hydrated) return;
265
+ el.removeAttribute("data-island-state");
266
+ for (const ev of events) el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS);
267
+ };
268
+ }
269
+ const INTERACTION_LISTENER_OPTS = {
270
+ passive: false,
271
+ capture: true
272
+ };
273
+ function captureReplayPath(el, target) {
274
+ const testid = target.getAttribute?.("data-testid");
275
+ if (testid) return {
276
+ kind: "testid",
277
+ value: testid
278
+ };
279
+ const steps = [];
280
+ let node = target;
281
+ while (node !== el) {
282
+ const parent = node.parentElement;
283
+ if (!parent) return null;
284
+ const index = Array.from(parent.children).indexOf(node);
285
+ if (index < 0) return null;
286
+ steps.unshift({
287
+ tag: node.tagName,
288
+ index
289
+ });
290
+ node = parent;
291
+ }
292
+ return {
293
+ kind: "path",
294
+ steps
295
+ };
296
+ }
297
+ function resolveReplayPath(el, path) {
298
+ if (path.kind === "testid") return el.querySelector(`[data-testid="${path.value}"]`);
299
+ let node = el;
300
+ for (const { tag, index } of path.steps) {
301
+ const child = node?.children[index];
302
+ if (!child || child.tagName !== tag) return null;
303
+ node = child;
304
+ }
305
+ return node;
306
+ }
117
307
  async function hydrateIsland(el, loader, propsJson) {
118
308
  const name = el.getAttribute("data-component") ?? "unknown";
119
309
  try {
@@ -123,15 +313,21 @@ async function hydrateIsland(el, loader, propsJson) {
123
313
  if (typeof props !== "object" || props === null || Array.isArray(props)) throw new TypeError("Expected object");
124
314
  } catch (parseErr) {
125
315
  console.error(`Invalid island props JSON for "${name}"`, parseErr);
316
+ el.setAttribute("data-island-error", "invalid-props");
317
+ if (__DEV__) _countSink.__pyreon_count__?.("island.error");
126
318
  return;
127
319
  }
128
320
  const mod = await loader();
129
321
  hydrateRoot(el, h(typeof mod === "function" ? mod : mod.default, props));
322
+ if (__DEV__) _countSink.__pyreon_count__?.("island.hydrated");
130
323
  } catch (err) {
131
324
  console.error(`Failed to hydrate island "${name}"`, err);
325
+ el.setAttribute("data-island-error", "hydration-failed");
326
+ if (__DEV__) _countSink.__pyreon_count__?.("island.error");
132
327
  }
133
328
  }
134
329
  function observeVisibility(el, callback) {
330
+ /* v8 ignore next */
135
331
  if (typeof window === "undefined") return null;
136
332
  if (!("IntersectionObserver" in window)) {
137
333
  callback();
@@ -149,5 +345,5 @@ function observeVisibility(el, callback) {
149
345
  }
150
346
 
151
347
  //#endregion
152
- export { hydrateIslands, startClient };
348
+ export { hydrateIslands, hydrateIslandsAuto, startClient };
153
349
  //# sourceMappingURL=client.js.map
package/lib/index.js CHANGED
@@ -1,108 +1,10 @@
1
- import { createContext, h, provide, pushContext, useContext } from "@pyreon/core";
2
- import { renderToStream, renderToString, runWithRequestContext } from "@pyreon/runtime-server";
3
- import { RouterProvider, createRouter, prefetchLoaderData, serializeLoaderData } from "@pyreon/router";
1
+ import { createContext, h, provide, useContext } from "@pyreon/core";
2
+ import { renderWithHead } from "@pyreon/head/ssr";
3
+ import { RouterProvider, createRouter, getRedirectInfo, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from "@pyreon/router";
4
+ import { renderToStream, runWithRequestContext } from "@pyreon/runtime-server";
4
5
  import { mkdir, writeFile } from "node:fs/promises";
5
6
  import { dirname, join, resolve } from "node:path";
6
7
 
7
- //#region ../head/lib/ssr.js
8
- function createHeadContext() {
9
- const map = /* @__PURE__ */ new Map();
10
- let dirty = true;
11
- let cachedTags = [];
12
- let cachedTitleTemplate;
13
- let cachedHtmlAttrs = {};
14
- let cachedBodyAttrs = {};
15
- function rebuild() {
16
- if (!dirty) return;
17
- dirty = false;
18
- const keyed = /* @__PURE__ */ new Map();
19
- const unkeyed = [];
20
- let titleTemplate;
21
- const htmlAttrs = {};
22
- const bodyAttrs = {};
23
- for (const entry of map.values()) {
24
- for (const tag of entry.tags) if (tag.key) keyed.set(tag.key, tag);
25
- else unkeyed.push(tag);
26
- if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
27
- if (entry.htmlAttrs) Object.assign(htmlAttrs, entry.htmlAttrs);
28
- if (entry.bodyAttrs) Object.assign(bodyAttrs, entry.bodyAttrs);
29
- }
30
- cachedTags = [...keyed.values(), ...unkeyed];
31
- cachedTitleTemplate = titleTemplate;
32
- cachedHtmlAttrs = htmlAttrs;
33
- cachedBodyAttrs = bodyAttrs;
34
- }
35
- return {
36
- add(id, entry) {
37
- map.set(id, entry);
38
- dirty = true;
39
- },
40
- remove(id) {
41
- map.delete(id);
42
- dirty = true;
43
- },
44
- resolve() {
45
- rebuild();
46
- return cachedTags;
47
- },
48
- resolveTitleTemplate() {
49
- rebuild();
50
- return cachedTitleTemplate;
51
- },
52
- resolveHtmlAttrs() {
53
- rebuild();
54
- return cachedHtmlAttrs;
55
- },
56
- resolveBodyAttrs() {
57
- rebuild();
58
- return cachedBodyAttrs;
59
- }
60
- };
61
- }
62
- const HeadContext = createContext(null);
63
- const VOID_TAGS = new Set([
64
- "meta",
65
- "link",
66
- "base"
67
- ]);
68
- async function renderWithHead(app) {
69
- const ctx = createHeadContext();
70
- function HeadInjector() {
71
- pushContext(new Map([[HeadContext.id, ctx]]));
72
- return app;
73
- }
74
- const html = await renderToString(h(HeadInjector, null));
75
- const titleTemplate = ctx.resolveTitleTemplate();
76
- return {
77
- html,
78
- head: ctx.resolve().map((tag) => serializeTag(tag, titleTemplate)).join("\n "),
79
- htmlAttrs: ctx.resolveHtmlAttrs(),
80
- bodyAttrs: ctx.resolveBodyAttrs()
81
- };
82
- }
83
- function serializeTag(tag, titleTemplate) {
84
- if (tag.tag === "title") {
85
- const raw = tag.children || "";
86
- return `<title>${esc(titleTemplate ? typeof titleTemplate === "function" ? titleTemplate(raw) : titleTemplate.replace(/%s/g, raw) : raw)}</title>`;
87
- }
88
- const props = tag.props;
89
- const attrs = props ? Object.entries(props).map(([k, v]) => `${k}="${esc(v)}"`).join(" ") : "";
90
- const open = attrs ? `<${tag.tag} ${attrs}` : `<${tag.tag}`;
91
- if (VOID_TAGS.has(tag.tag)) return `${open} />`;
92
- return `${open}>${(tag.children || "").replace(/<\/(script|style|noscript)/gi, "<\\/$1").replace(/<!--/g, "<\\!--")}</${tag.tag}>`;
93
- }
94
- const ESC_RE = /[&<>"]/g;
95
- const ESC_MAP = {
96
- "&": "&amp;",
97
- "<": "&lt;",
98
- ">": "&gt;",
99
- "\"": "&quot;"
100
- };
101
- function esc(s) {
102
- return ESC_RE.test(s) ? s.replace(ESC_RE, (ch) => ESC_MAP[ch]) : s;
103
- }
104
-
105
- //#endregion
106
8
  //#region src/html.ts
107
9
  /**
108
10
  * HTML template processing for SSR/SSG.
@@ -159,7 +61,7 @@ function processCompiledTemplate(compiled, data) {
159
61
  function buildScripts(clientEntry, loaderData) {
160
62
  const parts = [];
161
63
  if (loaderData && Object.keys(loaderData).length > 0) {
162
- const json = JSON.stringify(loaderData).replace(/<\//g, "<\\/");
64
+ const json = stringifyLoaderData(loaderData);
163
65
  parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}<\/script>`);
164
66
  }
165
67
  parts.push(`<script type="module" src="${clientEntry}"><\/script>`);
@@ -171,7 +73,7 @@ function buildClientEntryTag(clientEntry) {
171
73
  }
172
74
  /** Fast path: build scripts with a pre-built client entry tag */
173
75
  function buildScriptsFast(clientEntryTag, loaderData) {
174
- if (loaderData && Object.keys(loaderData).length > 0) return `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}<\/script>\n ${clientEntryTag}`;
76
+ if (loaderData && Object.keys(loaderData).length > 0) return `<script>window.__PYREON_LOADER_DATA__=${stringifyLoaderData(loaderData)}<\/script>\n ${clientEntryTag}`;
175
77
  return clientEntryTag;
176
78
  }
177
79
 
@@ -264,7 +166,7 @@ function createHandler(options) {
264
166
  return runWithRequestContext(async () => {
265
167
  try {
266
168
  provideRequestLocals(ctx.locals);
267
- await prefetchLoaderData(router, path);
169
+ await prefetchLoaderData(router, path, req);
268
170
  const app = h(RouterProvider, { router }, h(App, null));
269
171
  if (mode === "stream") return renderStreamResponse(app, router, compiled, clientEntryTag, ctx.headers);
270
172
  const { html: appHtml, head } = await renderWithHead(app);
@@ -275,11 +177,17 @@ function createHandler(options) {
275
177
  app: appHtml,
276
178
  scripts
277
179
  });
180
+ const status = router.currentRoute()?.isNotFound === true ? 404 : 200;
278
181
  return new Response(fullHtml, {
279
- status: 200,
182
+ status,
280
183
  headers: ctx.headers
281
184
  });
282
185
  } catch (err) {
186
+ const info = getRedirectInfo(err);
187
+ if (info) return new Response(null, {
188
+ status: info.status,
189
+ headers: { Location: info.url }
190
+ });
283
191
  if (__DEV__) console.error("[Pyreon Server] SSR render failed:", err);
284
192
  return new Response("Internal Server Error", {
285
193
  status: 500,
@@ -314,6 +222,7 @@ async function renderStreamResponse(app, router, compiled, clientEntryTag, extra
314
222
  }
315
223
  push(shellTail);
316
224
  } catch (err) {
225
+ /* v8 ignore start */
317
226
  if (__DEV__) console.error("[Pyreon Server] Stream render failed:", err);
318
227
  push(`<script>console.error("[pyreon/server] Stream render failed")<\/script>`);
319
228
  push(shellTail);
@@ -338,15 +247,17 @@ async function renderStreamResponse(app, router, compiled, clientEntryTag, extra
338
247
  * 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy
339
248
  */
340
249
  function island(loader, options) {
341
- const { name, hydrate = "load" } = options;
250
+ const { name, hydrate = "load", prefetch = "none" } = options;
342
251
  const wrapper = async function IslandWrapper(props) {
343
252
  const mod = await loader();
344
253
  const Comp = typeof mod === "function" ? mod : mod.default;
345
- return h("pyreon-island", {
254
+ const attrs = {
346
255
  "data-component": name,
347
- "data-props": serializeIslandProps(props),
256
+ "data-props": serializeIslandProps(props, name),
348
257
  "data-hydrate": hydrate
349
- }, h(Comp, props));
258
+ };
259
+ if (prefetch !== "none" && hydrate !== "load" && hydrate !== "never") attrs["data-prefetch"] = prefetch;
260
+ return h("pyreon-island", attrs, h(Comp, props));
350
261
  };
351
262
  Object.defineProperties(wrapper, {
352
263
  __island: {
@@ -362,24 +273,51 @@ function island(loader, options) {
362
273
  hydrate: {
363
274
  value: hydrate,
364
275
  enumerable: true
276
+ },
277
+ prefetch: {
278
+ value: prefetch,
279
+ enumerable: true
365
280
  }
366
281
  });
367
282
  return wrapper;
368
283
  }
369
284
  /**
370
- * Serialize component props to a JSON string for embedding in HTML attributes.
371
- * Strips non-serializable values (functions, symbols, children).
285
+ * Serialize island props to JSON for embedding in `data-props`.
286
+ *
287
+ * **Prop contract** (what survives the SSR → client roundtrip):
288
+ *
289
+ * - ✅ JSON-native: strings, finite numbers, booleans, null, arrays, plain objects
290
+ * - ❌ **Dropped silently**: `children`, functions, symbols, `undefined` (a warning
291
+ * fires in dev when `children` is dropped — it's the most common surprise)
292
+ * - ❌ **Coerced**: `Date` becomes an ISO string (no auto-revival on the client),
293
+ * `Map` / `Set` / class instances lose their type
294
+ * - ⚠️ **`BigInt` is unsupported**: `JSON.stringify` throws on `BigInt` values.
295
+ * We catch the throw, log in dev, and emit `{}` rather than 500ing the SSR.
296
+ * Convert to string yourself before passing as a prop.
297
+ *
298
+ * For anything more complex than JSON, pass an ID and have the island component
299
+ * fetch / restore the rich value on the client.
372
300
  */
373
- function serializeIslandProps(props) {
301
+ function serializeIslandProps(props, islandName) {
374
302
  const clean = {};
303
+ let droppedChildren = false;
375
304
  for (const [key, value] of Object.entries(props)) {
376
- if (key === "children") continue;
305
+ if (key === "children") {
306
+ if (value !== void 0) droppedChildren = true;
307
+ continue;
308
+ }
377
309
  if (typeof value === "function") continue;
378
310
  if (typeof value === "symbol") continue;
379
311
  if (value === void 0) continue;
380
312
  clean[key] = value;
381
313
  }
382
- return JSON.stringify(clean);
314
+ if (droppedChildren && process.env.NODE_ENV !== "production") console.warn(`[Pyreon] island "${islandName}" was passed children, but island props do not support children — they were dropped. Render the children inside the island component itself.`);
315
+ try {
316
+ return JSON.stringify(clean);
317
+ } catch (err) {
318
+ if (process.env.NODE_ENV !== "production") console.error(`[Pyreon] island "${islandName}" props could not be serialized (likely BigInt or circular reference). Falling back to empty props. Original error: ${err.message}`);
319
+ return "{}";
320
+ }
383
321
  }
384
322
 
385
323
  //#endregion
@@ -28,19 +28,58 @@ type IslandLoader = () => Promise<{
28
28
  * Hydrate all `<pyreon-island>` elements on the page.
29
29
  *
30
30
  * Only loads JavaScript for components that are actually present in the HTML.
31
- * Respects hydration strategies (load, idle, visible, media, never).
31
+ * Respects hydration strategies (load, idle, visible, media, never). Returns
32
+ * a cleanup function that disconnects any pending observers/listeners.
33
+ *
34
+ * **`hydrate: 'never'` islands do NOT require a registry entry** — the whole
35
+ * point of the strategy is shipping zero client JS, so importing the loader
36
+ * (which would pull the component into the client bundle graph) defeats it.
37
+ * Such islands are silently skipped here without a `data-island-error` flag.
32
38
  *
33
39
  * @example
34
40
  * hydrateIslands({
35
41
  * Counter: () => import("./Counter"),
36
42
  * Search: () => import("./Search"),
43
+ * // No entry for `StaticBadge` even though it appears as a never-island
44
+ * // in the HTML — registering one would defeat the strategy.
37
45
  * })
38
46
  */
47
+ declare function hydrateIslands(registry: Record<string, IslandLoader>): () => void;
39
48
  /**
40
- * Hydrate all `<pyreon-island>` elements on the page.
41
- * Returns a cleanup function that disconnects any pending observers/listeners.
49
+ * Auto-discovered island registry shape emitted by `@pyreon/vite-plugin`
50
+ * as `virtual:pyreon/islands-registry`. The user passes the imported module
51
+ * to `hydrateIslandsAuto()`.
42
52
  */
43
- declare function hydrateIslands(registry: Record<string, IslandLoader>): () => void;
53
+ interface AutoIslandRegistry {
54
+ readonly __pyreonIslandRegistry: Record<string, IslandLoader>;
55
+ readonly __pyreonIslandsEnabled: boolean;
56
+ }
57
+ /**
58
+ * Hydrate all `<pyreon-island>` elements using a registry auto-generated by
59
+ * `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default).
60
+ *
61
+ * Eliminates the manual sync between `island()` declarations in source and
62
+ * the client-side `hydrateIslands({ ... })` call — typo / forgotten entry /
63
+ * registry drift is the #1 author foot-gun for islands.
64
+ *
65
+ * The auto-registry omits `hydrate: 'never'` islands by design; their
66
+ * components stay out of the client bundle entirely. Other strategies
67
+ * resolve via the same dynamic-import paths their `island()` declaration
68
+ * specified.
69
+ *
70
+ * The user passes the virtual-module result. We don't import it inside
71
+ * `@pyreon/server/client` because Rolldown's static-import analysis runs
72
+ * before plugin resolveId hooks for workspace sources, and would fail to
73
+ * resolve the virtual specifier. Importing in the user's entry-client (where
74
+ * the plugin's resolveId fires natively) is the clean shape.
75
+ *
76
+ * @example
77
+ * // src/entry-client.ts
78
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
79
+ * import * as registry from 'virtual:pyreon/islands-registry'
80
+ * hydrateIslandsAuto(registry)
81
+ */
82
+ declare function hydrateIslandsAuto(registry: AutoIslandRegistry): () => void;
44
83
  //#endregion
45
- export { StartClientOptions, hydrateIslands, startClient };
84
+ export { AutoIslandRegistry, StartClientOptions, hydrateIslands, hydrateIslandsAuto, startClient };
46
85
  //# sourceMappingURL=client2.d.ts.map