@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.
- package/lib/analysis/client.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/client.js +206 -10
- package/lib/index.js +53 -115
- package/lib/types/client.d.ts +44 -5
- package/lib/types/index.d.ts +9 -9
- package/package.json +12 -8
- package/src/client.ts +340 -11
- package/src/handler.ts +34 -4
- package/src/html.ts +7 -3
- package/src/island.ts +109 -24
- package/src/manifest.ts +65 -9
- package/src/tests/client.test.ts +915 -1
- package/src/tests/islands.browser.test.tsx +512 -0
- package/src/tests/manifest-snapshot.test.ts +2 -0
- package/src/tests/server.test.ts +296 -1
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -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":"
|
|
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":"
|
|
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
|
|
60
|
-
const
|
|
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 (
|
|
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 "
|
|
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,
|
|
2
|
-
import {
|
|
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
|
-
"&": "&",
|
|
97
|
-
"<": "<",
|
|
98
|
-
">": ">",
|
|
99
|
-
"\"": """
|
|
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 =
|
|
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__=${
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
371
|
-
*
|
|
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")
|
|
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
|
-
|
|
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
|
package/lib/types/client.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|