@pyreon/server 0.15.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 +44 -13
- package/lib/types/client.d.ts +44 -5
- package/lib/types/index.d.ts +9 -9
- package/package.json +11 -8
- package/src/client.ts +340 -11
- package/src/handler.ts +17 -2
- 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 +220 -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":"src","children":[{"uid":"
|
|
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,6 +1,6 @@
|
|
|
1
1
|
import { createContext, h, provide, useContext } from "@pyreon/core";
|
|
2
2
|
import { renderWithHead } from "@pyreon/head/ssr";
|
|
3
|
-
import { RouterProvider, createRouter, getRedirectInfo, prefetchLoaderData, serializeLoaderData } from "@pyreon/router";
|
|
3
|
+
import { RouterProvider, createRouter, getRedirectInfo, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from "@pyreon/router";
|
|
4
4
|
import { renderToStream, runWithRequestContext } from "@pyreon/runtime-server";
|
|
5
5
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -61,7 +61,7 @@ function processCompiledTemplate(compiled, data) {
|
|
|
61
61
|
function buildScripts(clientEntry, loaderData) {
|
|
62
62
|
const parts = [];
|
|
63
63
|
if (loaderData && Object.keys(loaderData).length > 0) {
|
|
64
|
-
const json =
|
|
64
|
+
const json = stringifyLoaderData(loaderData);
|
|
65
65
|
parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}<\/script>`);
|
|
66
66
|
}
|
|
67
67
|
parts.push(`<script type="module" src="${clientEntry}"><\/script>`);
|
|
@@ -73,7 +73,7 @@ function buildClientEntryTag(clientEntry) {
|
|
|
73
73
|
}
|
|
74
74
|
/** Fast path: build scripts with a pre-built client entry tag */
|
|
75
75
|
function buildScriptsFast(clientEntryTag, loaderData) {
|
|
76
|
-
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}`;
|
|
77
77
|
return clientEntryTag;
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -177,8 +177,9 @@ function createHandler(options) {
|
|
|
177
177
|
app: appHtml,
|
|
178
178
|
scripts
|
|
179
179
|
});
|
|
180
|
+
const status = router.currentRoute()?.isNotFound === true ? 404 : 200;
|
|
180
181
|
return new Response(fullHtml, {
|
|
181
|
-
status
|
|
182
|
+
status,
|
|
182
183
|
headers: ctx.headers
|
|
183
184
|
});
|
|
184
185
|
} catch (err) {
|
|
@@ -221,6 +222,7 @@ async function renderStreamResponse(app, router, compiled, clientEntryTag, extra
|
|
|
221
222
|
}
|
|
222
223
|
push(shellTail);
|
|
223
224
|
} catch (err) {
|
|
225
|
+
/* v8 ignore start */
|
|
224
226
|
if (__DEV__) console.error("[Pyreon Server] Stream render failed:", err);
|
|
225
227
|
push(`<script>console.error("[pyreon/server] Stream render failed")<\/script>`);
|
|
226
228
|
push(shellTail);
|
|
@@ -245,15 +247,17 @@ async function renderStreamResponse(app, router, compiled, clientEntryTag, extra
|
|
|
245
247
|
* 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy
|
|
246
248
|
*/
|
|
247
249
|
function island(loader, options) {
|
|
248
|
-
const { name, hydrate = "load" } = options;
|
|
250
|
+
const { name, hydrate = "load", prefetch = "none" } = options;
|
|
249
251
|
const wrapper = async function IslandWrapper(props) {
|
|
250
252
|
const mod = await loader();
|
|
251
253
|
const Comp = typeof mod === "function" ? mod : mod.default;
|
|
252
|
-
|
|
254
|
+
const attrs = {
|
|
253
255
|
"data-component": name,
|
|
254
|
-
"data-props": serializeIslandProps(props),
|
|
256
|
+
"data-props": serializeIslandProps(props, name),
|
|
255
257
|
"data-hydrate": hydrate
|
|
256
|
-
}
|
|
258
|
+
};
|
|
259
|
+
if (prefetch !== "none" && hydrate !== "load" && hydrate !== "never") attrs["data-prefetch"] = prefetch;
|
|
260
|
+
return h("pyreon-island", attrs, h(Comp, props));
|
|
257
261
|
};
|
|
258
262
|
Object.defineProperties(wrapper, {
|
|
259
263
|
__island: {
|
|
@@ -269,24 +273,51 @@ function island(loader, options) {
|
|
|
269
273
|
hydrate: {
|
|
270
274
|
value: hydrate,
|
|
271
275
|
enumerable: true
|
|
276
|
+
},
|
|
277
|
+
prefetch: {
|
|
278
|
+
value: prefetch,
|
|
279
|
+
enumerable: true
|
|
272
280
|
}
|
|
273
281
|
});
|
|
274
282
|
return wrapper;
|
|
275
283
|
}
|
|
276
284
|
/**
|
|
277
|
-
* Serialize
|
|
278
|
-
*
|
|
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.
|
|
279
300
|
*/
|
|
280
|
-
function serializeIslandProps(props) {
|
|
301
|
+
function serializeIslandProps(props, islandName) {
|
|
281
302
|
const clean = {};
|
|
303
|
+
let droppedChildren = false;
|
|
282
304
|
for (const [key, value] of Object.entries(props)) {
|
|
283
|
-
if (key === "children")
|
|
305
|
+
if (key === "children") {
|
|
306
|
+
if (value !== void 0) droppedChildren = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
284
309
|
if (typeof value === "function") continue;
|
|
285
310
|
if (typeof value === "symbol") continue;
|
|
286
311
|
if (value === void 0) continue;
|
|
287
312
|
clean[key] = value;
|
|
288
313
|
}
|
|
289
|
-
|
|
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
|
+
}
|
|
290
321
|
}
|
|
291
322
|
|
|
292
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -102,14 +102,6 @@ interface HandlerOptions {
|
|
|
102
102
|
declare function createHandler(options: HandlerOptions): (req: Request) => Promise<Response>;
|
|
103
103
|
//#endregion
|
|
104
104
|
//#region src/html.d.ts
|
|
105
|
-
/**
|
|
106
|
-
* HTML template processing for SSR/SSG.
|
|
107
|
-
*
|
|
108
|
-
* Templates use comment placeholders:
|
|
109
|
-
* <!--pyreon-head--> — replaced with <head> tags (title, meta, link, etc.)
|
|
110
|
-
* <!--pyreon-app--> — replaced with rendered application HTML
|
|
111
|
-
* <!--pyreon-scripts--> — replaced with client entry script + inline loader data
|
|
112
|
-
*/
|
|
113
105
|
declare const DEFAULT_TEMPLATE = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <!--pyreon-head-->\n</head>\n<body>\n <div id=\"app\"><!--pyreon-app--></div>\n <!--pyreon-scripts-->\n</body>\n</html>";
|
|
114
106
|
interface TemplateData {
|
|
115
107
|
head: string;
|
|
@@ -138,17 +130,25 @@ declare function processCompiledTemplate(compiled: CompiledTemplate, data: Templ
|
|
|
138
130
|
declare function buildScripts(clientEntry: string, loaderData: Record<string, unknown> | null): string;
|
|
139
131
|
//#endregion
|
|
140
132
|
//#region src/island.d.ts
|
|
141
|
-
type HydrationStrategy = 'load' | 'idle' | 'visible' | 'never' | `media(${string})`;
|
|
133
|
+
type HydrationStrategy = 'load' | 'idle' | 'visible' | 'interaction' | 'never' | `media(${string})` | `interaction(${string})`;
|
|
134
|
+
type PrefetchStrategy = 'none' | 'idle' | 'visible';
|
|
142
135
|
interface IslandOptions {
|
|
143
136
|
/** Unique name — must match the key in the client-side hydrateIslands() registry */
|
|
144
137
|
name: string;
|
|
145
138
|
/** When to hydrate on the client (default: "load") */
|
|
146
139
|
hydrate?: HydrationStrategy;
|
|
140
|
+
/**
|
|
141
|
+
* Pre-warm the island's chunk before its hydration trigger fires.
|
|
142
|
+
* Best paired with `hydrate: 'visible'` or `hydrate: 'media(...)'`.
|
|
143
|
+
* Default: "none". No-op when paired with `hydrate: 'load'` or `'never'`.
|
|
144
|
+
*/
|
|
145
|
+
prefetch?: PrefetchStrategy;
|
|
147
146
|
}
|
|
148
147
|
interface IslandMeta {
|
|
149
148
|
readonly __island: true;
|
|
150
149
|
readonly name: string;
|
|
151
150
|
readonly hydrate: HydrationStrategy;
|
|
151
|
+
readonly prefetch: PrefetchStrategy;
|
|
152
152
|
}
|
|
153
153
|
/**
|
|
154
154
|
* Create an island component.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "SSR handler, SSG prerender, and island architecture for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,19 +43,22 @@
|
|
|
43
43
|
"build": "vl_rolldown_build",
|
|
44
44
|
"dev": "vl_rolldown_build-watch",
|
|
45
45
|
"test": "vitest run",
|
|
46
|
+
"test:browser": "vitest run --config ./vitest.browser.config.ts",
|
|
46
47
|
"typecheck": "tsc --noEmit",
|
|
47
48
|
"lint": "oxlint .",
|
|
48
49
|
"prepublishOnly": "bun run build"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@pyreon/core": "^0.
|
|
52
|
-
"@pyreon/head": "^0.
|
|
53
|
-
"@pyreon/reactivity": "^0.
|
|
54
|
-
"@pyreon/router": "^0.
|
|
55
|
-
"@pyreon/runtime-dom": "^0.
|
|
56
|
-
"@pyreon/runtime-server": "^0.
|
|
52
|
+
"@pyreon/core": "^0.16.0",
|
|
53
|
+
"@pyreon/head": "^0.16.0",
|
|
54
|
+
"@pyreon/reactivity": "^0.16.0",
|
|
55
|
+
"@pyreon/router": "^0.16.0",
|
|
56
|
+
"@pyreon/runtime-dom": "^0.16.0",
|
|
57
|
+
"@pyreon/runtime-server": "^0.16.0"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
|
-
"@pyreon/manifest": "0.13.1"
|
|
60
|
+
"@pyreon/manifest": "0.13.1",
|
|
61
|
+
"@vitest/browser": "^4.1.4",
|
|
62
|
+
"@vitest/browser-playwright": "^4.1.4"
|
|
60
63
|
}
|
|
61
64
|
}
|