@pylonsync/functions 0.3.308 → 0.3.310
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/dist/runtime.d.ts +1 -1
- package/dist/ssr-client-bundler.d.ts +3 -0
- package/dist/ssr-route-match.d.ts +20 -0
- package/dist/ssr-runtime.d.ts +1 -1
- package/package.json +1 -1
- package/src/runtime.ts +31 -7
- package/src/ssr-client-bundler.ts +161 -16
- package/src/ssr-form-runtime.ts +6 -2
- package/src/ssr-route-match.test.ts +83 -0
- package/src/ssr-route-match.ts +104 -0
- package/src/ssr-runtime.test.ts +48 -0
- package/src/ssr-runtime.ts +37 -1
package/dist/runtime.d.ts
CHANGED
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
* needs to queue multiple RPCs per call_id.
|
|
16
16
|
*/
|
|
17
17
|
import type { DbReader, DbWriter } from "./types";
|
|
18
|
-
export declare function buildDbReader(callId: string): DbReader;
|
|
18
|
+
export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
|
|
19
19
|
export declare function buildDbWriter(callId: string): DbWriter;
|
|
@@ -40,6 +40,9 @@ export interface PylonBundleManifest {
|
|
|
40
40
|
imports: string[];
|
|
41
41
|
/** CSS chunks (Phase 1.5f). */
|
|
42
42
|
css: string[];
|
|
43
|
+
/** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
|
|
44
|
+
* matcher resolve an href to this route for optimistic navigation. */
|
|
45
|
+
path?: string;
|
|
43
46
|
}>;
|
|
44
47
|
/** Self-hosted fonts (next/font parity): structured `@font-face`s + the
|
|
45
48
|
* `:root` CSS variables + the woff2 files to preload. Global (route-
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RouteMatch {
|
|
2
|
+
/** Route component path — the manifest key + __PYLON_DATA__.component value. */
|
|
3
|
+
component: string;
|
|
4
|
+
/** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
/** The slice of the build manifest this matcher needs. */
|
|
8
|
+
export interface MatchableManifest {
|
|
9
|
+
routes: Record<string, {
|
|
10
|
+
path?: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a concrete pathname to the route that would render it, plus its
|
|
15
|
+
* decoded dynamic params. Returns null when no page route matches (the caller
|
|
16
|
+
* then falls back to a normal server round-trip). When several patterns match,
|
|
17
|
+
* the most specific wins: fewer catch-alls first, then fewer dynamic segments —
|
|
18
|
+
* so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function matchRoute(manifest: MatchableManifest | null | undefined, pathname: string): RouteMatch | null;
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -181,7 +181,7 @@ export declare function makeRevocableReadTrackingProxy(obj: Record<string, unkno
|
|
|
181
181
|
proxy: Record<string, unknown>;
|
|
182
182
|
revoke: () => void;
|
|
183
183
|
};
|
|
184
|
-
export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string
|
|
184
|
+
export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>, effectiveStatus?: number): Record<string, string>;
|
|
185
185
|
/**
|
|
186
186
|
* Phase 1 SSR handler. Resolves the component, renders it via
|
|
187
187
|
* react-dom/server.renderToReadableStream, pumps chunks back to the
|
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -391,11 +391,24 @@ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
|
|
|
391
391
|
// `serverData` read handle that reuses this module's `send` + `pendingRpcs`
|
|
392
392
|
// + reader loop. The render call_id ("r_<n>") correlates DB replies back
|
|
393
393
|
// through the shared pendingRpcs map.
|
|
394
|
-
export function buildDbReader(callId: string): DbReader {
|
|
395
|
-
return {
|
|
394
|
+
export function buildDbReader(callId: string, ssrRead = false): DbReader {
|
|
395
|
+
return {
|
|
396
|
+
...buildReaderOps(callId, false, ssrRead),
|
|
397
|
+
unsafe: buildReaderOps(callId, true, ssrRead),
|
|
398
|
+
};
|
|
396
399
|
}
|
|
397
400
|
|
|
398
|
-
function buildReaderOps(
|
|
401
|
+
function buildReaderOps(
|
|
402
|
+
callId: string,
|
|
403
|
+
unsafeOp: boolean,
|
|
404
|
+
// `ssrRead`: true when the reader backs SSR `serverData.*` (results are
|
|
405
|
+
// serialized into the client-visible `__PYLON_DATA__` blob). The Rust side
|
|
406
|
+
// then applies the same per-row policy filter + `server_only`/`passwordHash`
|
|
407
|
+
// projection the entity/sync read API does — UNLESS `unsafe_op` is also set
|
|
408
|
+
// (`serverData.unsafe.*` stays server-trust). Server-function `ctx.db.*`
|
|
409
|
+
// leaves it false and reads raw.
|
|
410
|
+
ssrRead = false,
|
|
411
|
+
): Omit<DbReader, "unsafe"> {
|
|
399
412
|
// All DB ops use rpcDb so Promise.all over ctx.db reads can run in
|
|
400
413
|
// parallel without colliding on the outer call_id key.
|
|
401
414
|
//
|
|
@@ -412,6 +425,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
412
425
|
entity,
|
|
413
426
|
id,
|
|
414
427
|
unsafe_op: unsafeOp,
|
|
428
|
+
ssr_read: ssrRead,
|
|
415
429
|
})) as any;
|
|
416
430
|
},
|
|
417
431
|
async list(entity) {
|
|
@@ -420,6 +434,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
420
434
|
op: "list",
|
|
421
435
|
entity,
|
|
422
436
|
unsafe_op: unsafeOp,
|
|
437
|
+
ssr_read: ssrRead,
|
|
423
438
|
})) as any;
|
|
424
439
|
},
|
|
425
440
|
async lookup(entity, field, value) {
|
|
@@ -430,6 +445,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
430
445
|
field,
|
|
431
446
|
value,
|
|
432
447
|
unsafe_op: unsafeOp,
|
|
448
|
+
ssr_read: ssrRead,
|
|
433
449
|
})) as any;
|
|
434
450
|
},
|
|
435
451
|
async query(entity, filter) {
|
|
@@ -439,6 +455,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
439
455
|
entity,
|
|
440
456
|
data: filter,
|
|
441
457
|
unsafe_op: unsafeOp,
|
|
458
|
+
ssr_read: ssrRead,
|
|
442
459
|
})) as any;
|
|
443
460
|
},
|
|
444
461
|
async queryGraph(query) {
|
|
@@ -448,6 +465,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
448
465
|
entity: "",
|
|
449
466
|
data: query,
|
|
450
467
|
unsafe_op: unsafeOp,
|
|
468
|
+
ssr_read: ssrRead,
|
|
451
469
|
})) as any;
|
|
452
470
|
},
|
|
453
471
|
async paginate(entity, opts) {
|
|
@@ -461,6 +479,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
461
479
|
after: opts.cursor ?? undefined,
|
|
462
480
|
limit: numItems,
|
|
463
481
|
unsafe_op: unsafeOp,
|
|
482
|
+
ssr_read: ssrRead,
|
|
464
483
|
})) as any;
|
|
465
484
|
},
|
|
466
485
|
async search(entity, query) {
|
|
@@ -470,6 +489,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
|
|
|
470
489
|
entity,
|
|
471
490
|
data: query,
|
|
472
491
|
unsafe_op: unsafeOp,
|
|
492
|
+
ssr_read: ssrRead,
|
|
473
493
|
})) as any;
|
|
474
494
|
},
|
|
475
495
|
};
|
|
@@ -1057,11 +1077,15 @@ async function main() {
|
|
|
1057
1077
|
send({ type: "ready", functions });
|
|
1058
1078
|
|
|
1059
1079
|
// Belt-and-suspenders against orphaning: if the host dies in a way that
|
|
1060
|
-
// somehow leaves our stdin open, we'll have been
|
|
1061
|
-
// (
|
|
1062
|
-
//
|
|
1080
|
+
// somehow leaves our stdin open, we'll have been REPARENTED — our ppid
|
|
1081
|
+
// changes (to init or the nearest subreaper). Compare against the ppid we
|
|
1082
|
+
// were born with rather than testing `ppid === 1`: in a container the
|
|
1083
|
+
// host pylon usually IS PID 1, so every healthy runner is born with
|
|
1084
|
+
// ppid 1 and the equality check kills the whole pool in a 2s respawn
|
|
1085
|
+
// loop. Unref'd so the watch never keeps us alive on its own.
|
|
1086
|
+
const initialPpid = process.ppid;
|
|
1063
1087
|
const orphanWatch = setInterval(() => {
|
|
1064
|
-
if (process.ppid
|
|
1088
|
+
if (process.ppid !== initialPpid) process.exit(0);
|
|
1065
1089
|
}, 2000);
|
|
1066
1090
|
if (typeof orphanWatch.unref === "function") orphanWatch.unref();
|
|
1067
1091
|
|
|
@@ -61,6 +61,12 @@ interface DiscoveredRoute {
|
|
|
61
61
|
component: string;
|
|
62
62
|
/** Layout chain root → leaf, same format as `component`. */
|
|
63
63
|
layouts: string[];
|
|
64
|
+
/**
|
|
65
|
+
* URL pattern this page serves (e.g. `/`, `/p/[slug]`). Set for `page`
|
|
66
|
+
* routes only — boundary modules (not-found/error) leave it undefined so the
|
|
67
|
+
* client route matcher (optimistic navigation) never resolves to them.
|
|
68
|
+
*/
|
|
69
|
+
pattern?: string;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** Bun.build returns this shape (the subset we depend on). */
|
|
@@ -120,7 +126,13 @@ function discoverRoutes(
|
|
|
120
126
|
return [];
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
type PageHit = {
|
|
129
|
+
type PageHit = {
|
|
130
|
+
segments: string[];
|
|
131
|
+
component: string;
|
|
132
|
+
layouts: string[];
|
|
133
|
+
// URL pattern, set for real pages only (boundary modules get none).
|
|
134
|
+
pattern?: string;
|
|
135
|
+
};
|
|
124
136
|
const pages: PageHit[] = [];
|
|
125
137
|
|
|
126
138
|
function walk(dir: string, segments: string[], layouts: string[]): void {
|
|
@@ -144,6 +156,8 @@ function discoverRoutes(
|
|
|
144
156
|
segments: [...segments],
|
|
145
157
|
component: path.relative(cwd, pageHere).replace(/\.(tsx?|jsx?)$/, ""),
|
|
146
158
|
layouts: nextLayouts,
|
|
159
|
+
// e.g. [] → "/", ["p","[slug]"] → "/p/[slug]".
|
|
160
|
+
pattern: "/" + segments.join("/"),
|
|
147
161
|
});
|
|
148
162
|
}
|
|
149
163
|
// Boundary modules (not-found.tsx / error.tsx) are hydrated like pages
|
|
@@ -176,6 +190,7 @@ function discoverRoutes(
|
|
|
176
190
|
return pages.map((p) => ({
|
|
177
191
|
component: p.component,
|
|
178
192
|
layouts: p.layouts,
|
|
193
|
+
pattern: p.pattern,
|
|
179
194
|
}));
|
|
180
195
|
}
|
|
181
196
|
|
|
@@ -209,6 +224,7 @@ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime
|
|
|
209
224
|
import { createElement } from "react";
|
|
210
225
|
import { hydrateRoot } from "react-dom/client";
|
|
211
226
|
import { createPylonBoundary } from "./client-boundary";
|
|
227
|
+
import { matchRoute } from "./route-match";
|
|
212
228
|
|
|
213
229
|
const routeCache = Object.create(null);
|
|
214
230
|
let activeRoot = null;
|
|
@@ -252,6 +268,13 @@ let navEpoch = 0;
|
|
|
252
268
|
// (for instance) redirect to /login instead of showing the not-found page.
|
|
253
269
|
let currentPageProps = {};
|
|
254
270
|
|
|
271
|
+
// Seed data for the in-flight navigation — the object handed to <Link seed>,
|
|
272
|
+
// exposed to the destination page via useRouteSeed() so it can paint its
|
|
273
|
+
// Suspense fallback with real content before the SSR fetch lands. Set at the
|
|
274
|
+
// start of every navigation (to the seed, or null), so useRouteSeed() reads a
|
|
275
|
+
// value that's consistent for the whole nav. Null on hard load / seedless nav.
|
|
276
|
+
let currentSeed = null;
|
|
277
|
+
|
|
255
278
|
// The not-found / error boundary lives in a real module (./client-boundary,
|
|
256
279
|
// emitted next to this file by the bundler) so its render behavior is
|
|
257
280
|
// unit-testable instead of buried in this string. Wire the runtime's
|
|
@@ -329,6 +352,32 @@ function makeClientServerData(ssrData) {
|
|
|
329
352
|
return sd;
|
|
330
353
|
}
|
|
331
354
|
|
|
355
|
+
// serverData stand-in used during the OPTIMISTIC first paint of a navigation:
|
|
356
|
+
// every method returns a never-resolving thenable, so a page that use()s it
|
|
357
|
+
// suspends and shows its Suspense fallback (which useRouteSeed() renders from
|
|
358
|
+
// the clicked-link seed). The thenable is cached per key so React never sees an
|
|
359
|
+
// "uncached promise" if it re-renders the optimistic tree. It never resolves on
|
|
360
|
+
// its own — navigate() replaces the whole tree with real, fulfilled serverData
|
|
361
|
+
// the moment the SSR fetch lands, which is what actually swaps fallback→content.
|
|
362
|
+
function makePendingServerData() {
|
|
363
|
+
const pc = new Map();
|
|
364
|
+
const pending = () => ({ then() {} });
|
|
365
|
+
const wrap = (prefix) => {
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const m of SERVER_DATA_METHODS) {
|
|
368
|
+
out[m] = (...args) => {
|
|
369
|
+
const key = prefix + m + ":" + stableStringify(args);
|
|
370
|
+
if (!pc.has(key)) pc.set(key, pending());
|
|
371
|
+
return pc.get(key);
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
};
|
|
376
|
+
const sd = wrap("");
|
|
377
|
+
sd.unsafe = wrap("u:");
|
|
378
|
+
return sd;
|
|
379
|
+
}
|
|
380
|
+
|
|
332
381
|
// Server-only response controller has no meaning on the client (the status/
|
|
333
382
|
// redirect/cookies already shipped). Give pages a no-op so a body that
|
|
334
383
|
// touches props.response during hydration doesn't crash.
|
|
@@ -353,8 +402,11 @@ function makeNoopResponse() {
|
|
|
353
402
|
// in a deep client child has a reactive source. A fresh object per nav → stable
|
|
354
403
|
// reference between navs (useSyncExternalStore needs that).
|
|
355
404
|
let currentParams = {};
|
|
405
|
+
function setNavParamsRaw(params) {
|
|
406
|
+
currentParams = params || {};
|
|
407
|
+
}
|
|
356
408
|
function setNavParams(data) {
|
|
357
|
-
|
|
409
|
+
setNavParamsRaw(data && data.props && data.props.params);
|
|
358
410
|
}
|
|
359
411
|
|
|
360
412
|
function withClientProps(data) {
|
|
@@ -530,9 +582,63 @@ async function navigate(href, opts) {
|
|
|
530
582
|
window.location.href = href;
|
|
531
583
|
return;
|
|
532
584
|
}
|
|
585
|
+
const target = url.pathname + url.search;
|
|
586
|
+
|
|
587
|
+
// Claim an epoch for this navigation. A later navigate() bumps navEpoch, so
|
|
588
|
+
// every await below can bail once it's been superseded by a newer nav —
|
|
589
|
+
// otherwise a slow fetch could render a stale destination over a newer one.
|
|
590
|
+
const myEpoch = ++navEpoch;
|
|
591
|
+
currentSeed = opts && opts.seed != null ? opts.seed : null;
|
|
592
|
+
|
|
593
|
+
// ---- Optimistic first paint --------------------------------------------
|
|
594
|
+
// With a seed AND a client-resolvable route, render the destination NOW —
|
|
595
|
+
// before the SSR fetch — with a pending serverData, so the page shows its
|
|
596
|
+
// Suspense fallback painted from the seed (via useRouteSeed). The real render
|
|
597
|
+
// below swaps in full data in place. Any failure (no route match, chunk load
|
|
598
|
+
// error) falls through to the normal blocking fetch-then-render path, so a
|
|
599
|
+
// seed can never break navigation. Requires the page to keep its serverData
|
|
600
|
+
// reads inside a <Suspense> (the store detail page does) — a bare top-level
|
|
601
|
+
// use() would suspend the whole page with no seeded fallback to show.
|
|
602
|
+
let didOptimistic = false;
|
|
603
|
+
if (currentSeed != null && activeRoot) {
|
|
604
|
+
try {
|
|
605
|
+
const manifest = await loadManifest();
|
|
606
|
+
if (myEpoch !== navEpoch) return;
|
|
607
|
+
const matched = matchRoute(manifest, url.pathname);
|
|
608
|
+
if (matched) {
|
|
609
|
+
const route = await loadRouteEntry(matched.component);
|
|
610
|
+
if (myEpoch !== navEpoch) return;
|
|
611
|
+
setNavParamsRaw(matched.params);
|
|
612
|
+
currentPageProps = {
|
|
613
|
+
params: matched.params,
|
|
614
|
+
serverData: makePendingServerData(),
|
|
615
|
+
response: makeNoopResponse(),
|
|
616
|
+
};
|
|
617
|
+
const tree = withBoundary(
|
|
618
|
+
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
619
|
+
matched.component,
|
|
620
|
+
myEpoch,
|
|
621
|
+
);
|
|
622
|
+
pendingNav = target;
|
|
623
|
+
activeRoot.render(tree);
|
|
624
|
+
if (opts && opts.replace) {
|
|
625
|
+
history.replaceState({ component: matched.component }, "", target);
|
|
626
|
+
} else if (push) {
|
|
627
|
+
history.pushState({ component: matched.component }, "", target);
|
|
628
|
+
}
|
|
629
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
630
|
+
window.scrollTo(0, 0);
|
|
631
|
+
didOptimistic = true;
|
|
632
|
+
}
|
|
633
|
+
} catch (e) {
|
|
634
|
+
// Fall through to the normal fetch-then-render path below.
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ---- Real fetch + render -----------------------------------------------
|
|
533
639
|
let html;
|
|
534
640
|
try {
|
|
535
|
-
const res = await fetch(
|
|
641
|
+
const res = await fetch(target, {
|
|
536
642
|
credentials: "same-origin",
|
|
537
643
|
headers: { Accept: "text/html" },
|
|
538
644
|
});
|
|
@@ -545,6 +651,7 @@ async function navigate(href, opts) {
|
|
|
545
651
|
window.location.href = href;
|
|
546
652
|
return;
|
|
547
653
|
}
|
|
654
|
+
if (myEpoch !== navEpoch) return;
|
|
548
655
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
549
656
|
const dataEl = doc.getElementById("__PYLON_DATA__");
|
|
550
657
|
if (!dataEl) {
|
|
@@ -566,16 +673,23 @@ async function navigate(href, opts) {
|
|
|
566
673
|
window.location.href = href;
|
|
567
674
|
return;
|
|
568
675
|
}
|
|
676
|
+
if (myEpoch !== navEpoch) return;
|
|
569
677
|
document.title = doc.title || document.title;
|
|
570
678
|
syncHeadMeta(doc);
|
|
571
679
|
setNavParams(data);
|
|
572
|
-
|
|
680
|
+
// Keep currentSeed set for the rest of this nav: useRouteData renders the seed
|
|
681
|
+
// as CONTENT (not a Suspense fallback) and upgrades it to real data in place,
|
|
682
|
+
// so the seed must stay readable across the optimistic→real render. It's reset
|
|
683
|
+
// at the START of the next navigation. (Clearing it here would degrade the
|
|
684
|
+
// page to its skeleton for a frame during the swap — a visible flash.)
|
|
573
685
|
currentPageProps = withClientProps(data);
|
|
574
|
-
|
|
686
|
+
// Reuse myEpoch (do NOT bump) so the optimistic tree and the real tree share
|
|
687
|
+
// a boundary identity: React resolves the Suspense fallback into the real
|
|
688
|
+
// content in place instead of remounting the whole subtree (no flash).
|
|
575
689
|
const tree = withBoundary(
|
|
576
690
|
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
577
691
|
data.component,
|
|
578
|
-
|
|
692
|
+
myEpoch,
|
|
579
693
|
);
|
|
580
694
|
// Track the in-flight destination so hydrateRoot's onUncaughtError can fall
|
|
581
695
|
// back to a full page load if this re-render throws in React's commit phase
|
|
@@ -586,17 +700,23 @@ async function navigate(href, opts) {
|
|
|
586
700
|
setTimeout(() => {
|
|
587
701
|
if (pendingNav === target) pendingNav = null;
|
|
588
702
|
}, 0);
|
|
589
|
-
if (
|
|
703
|
+
if (didOptimistic) {
|
|
704
|
+
// URL + scroll already handled during the optimistic paint; only keep the
|
|
705
|
+
// history entry's component in sync with the authoritative SSR component.
|
|
590
706
|
history.replaceState({ component: data.component }, "", target);
|
|
591
|
-
} else
|
|
592
|
-
|
|
707
|
+
} else {
|
|
708
|
+
if (opts && opts.replace) {
|
|
709
|
+
history.replaceState({ component: data.component }, "", target);
|
|
710
|
+
} else if (push) {
|
|
711
|
+
history.pushState({ component: data.component }, "", target);
|
|
712
|
+
}
|
|
713
|
+
// Notify the router hooks (useSearchParams / usePathname) so deep children
|
|
714
|
+
// re-read location after a Link click or a router.push(). popstate already
|
|
715
|
+
// covers back/forward, but pushState/replaceState fire no event.
|
|
716
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
717
|
+
// After a successful nav, scroll to top (Next.js default).
|
|
718
|
+
window.scrollTo(0, 0);
|
|
593
719
|
}
|
|
594
|
-
// Notify the router hooks (useSearchParams / usePathname) so deep children
|
|
595
|
-
// re-read location after a Link click or a router.push(). popstate already
|
|
596
|
-
// covers back/forward, but pushState/replaceState fire no event.
|
|
597
|
-
window.dispatchEvent(new Event("pylon:navigation"));
|
|
598
|
-
// After a successful nav, scroll to top (Next.js default).
|
|
599
|
-
window.scrollTo(0, 0);
|
|
600
720
|
}
|
|
601
721
|
|
|
602
722
|
function installNavHandlers() {
|
|
@@ -616,7 +736,12 @@ function installNavHandlers() {
|
|
|
616
736
|
return;
|
|
617
737
|
}
|
|
618
738
|
e.preventDefault();
|
|
619
|
-
|
|
739
|
+
// A <Link seed> registers its seed in window.__pylonLinkSeeds keyed by the
|
|
740
|
+
// anchor element; pass it through so navigate() can paint optimistically.
|
|
741
|
+
const seeds =
|
|
742
|
+
typeof window !== "undefined" ? window.__pylonLinkSeeds : undefined;
|
|
743
|
+
const seed = seeds ? seeds.get(link) : undefined;
|
|
744
|
+
navigate(href, seed != null ? { seed } : undefined);
|
|
620
745
|
});
|
|
621
746
|
window.addEventListener("popstate", () => {
|
|
622
747
|
navigate(location.pathname + location.search, { push: false });
|
|
@@ -677,6 +802,11 @@ const pylonGlobal = {
|
|
|
677
802
|
get params() {
|
|
678
803
|
return currentParams;
|
|
679
804
|
},
|
|
805
|
+
// Read by useRouteSeed(); the seed the active navigation was started with
|
|
806
|
+
// (from <Link seed>), or null. A getter so it always reflects the latest nav.
|
|
807
|
+
get seed() {
|
|
808
|
+
return currentSeed;
|
|
809
|
+
},
|
|
680
810
|
};
|
|
681
811
|
if (typeof window !== "undefined") {
|
|
682
812
|
window.__pylon = pylonGlobal;
|
|
@@ -759,6 +889,9 @@ export interface PylonBundleManifest {
|
|
|
759
889
|
imports: string[];
|
|
760
890
|
/** CSS chunks (Phase 1.5f). */
|
|
761
891
|
css: string[];
|
|
892
|
+
/** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
|
|
893
|
+
* matcher resolve an href to this route for optimistic navigation. */
|
|
894
|
+
path?: string;
|
|
762
895
|
}
|
|
763
896
|
>;
|
|
764
897
|
/** Self-hosted fonts (next/font parity): structured `@font-face`s + the
|
|
@@ -1044,6 +1177,15 @@ async function _doBuildInner(
|
|
|
1044
1177
|
"utf8",
|
|
1045
1178
|
);
|
|
1046
1179
|
|
|
1180
|
+
// Same pattern for the optimistic-navigation route matcher — the runtime
|
|
1181
|
+
// imports it as `./route-match`; its source of truth is ssr-route-match.ts,
|
|
1182
|
+
// unit-tested directly.
|
|
1183
|
+
fs.writeFileSync(
|
|
1184
|
+
path.join(stageDir, "route-match.ts"),
|
|
1185
|
+
fs.readFileSync(path.join(here, "ssr-route-match.ts"), "utf8"),
|
|
1186
|
+
"utf8",
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1047
1189
|
const entryPaths: string[] = [];
|
|
1048
1190
|
// entryPath (absolute) → component path (for manifest lookup).
|
|
1049
1191
|
const entryToComponent = new Map<string, string>();
|
|
@@ -1197,6 +1339,9 @@ async function _doBuildInner(
|
|
|
1197
1339
|
file: entry.relPath,
|
|
1198
1340
|
imports: Array.from(seen),
|
|
1199
1341
|
css: [],
|
|
1342
|
+
// Page routes carry their URL pattern for the client route matcher;
|
|
1343
|
+
// boundary modules (no pattern) are omitted so they never match.
|
|
1344
|
+
...(r.pattern ? { path: r.pattern } : {}),
|
|
1200
1345
|
};
|
|
1201
1346
|
}
|
|
1202
1347
|
|
package/src/ssr-form-runtime.ts
CHANGED
|
@@ -172,11 +172,15 @@ export async function handleForm(
|
|
|
172
172
|
for (const [k, v] of Object.entries(raw.headers ?? {})) {
|
|
173
173
|
extra[k.toLowerCase()] = String(v);
|
|
174
174
|
}
|
|
175
|
+
const rawStatus = raw.status ?? responseState.status;
|
|
175
176
|
send({
|
|
176
177
|
type: "response_start",
|
|
177
178
|
call_id: msg.call_id,
|
|
178
|
-
status:
|
|
179
|
-
|
|
179
|
+
status: rawStatus,
|
|
180
|
+
// Pass rawStatus so the open-redirect guard sees the handler's own
|
|
181
|
+
// status (a raw GET's redirect status lives on `raw.status`, not
|
|
182
|
+
// `responseState.status`).
|
|
183
|
+
headers: finalizeHeaders(responseState, extra, undefined, rawStatus),
|
|
180
184
|
});
|
|
181
185
|
if (raw.body != null) {
|
|
182
186
|
const bodyStr =
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { matchRoute, type MatchableManifest } from "./ssr-route-match";
|
|
3
|
+
|
|
4
|
+
const manifest: MatchableManifest = {
|
|
5
|
+
routes: {
|
|
6
|
+
"app/page": { path: "/" },
|
|
7
|
+
"app/account/page": { path: "/account" },
|
|
8
|
+
"app/checkout/page": { path: "/checkout" },
|
|
9
|
+
"app/p/[slug]/page": { path: "/p/[slug]" },
|
|
10
|
+
"app/orders/[id]/page": { path: "/orders/[id]" },
|
|
11
|
+
"app/orders/new/page": { path: "/orders/new" },
|
|
12
|
+
"app/docs/[...rest]/page": { path: "/docs/[...rest]" },
|
|
13
|
+
// Boundary modules ship without a `path` and must never match.
|
|
14
|
+
"app/p/[slug]/not-found": {},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("matchRoute", () => {
|
|
19
|
+
it("matches the index route", () => {
|
|
20
|
+
expect(matchRoute(manifest, "/")).toEqual({
|
|
21
|
+
component: "app/page",
|
|
22
|
+
params: {},
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("matches a static route", () => {
|
|
27
|
+
expect(matchRoute(manifest, "/account")).toEqual({
|
|
28
|
+
component: "app/account/page",
|
|
29
|
+
params: {},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("captures a dynamic segment and decodes it", () => {
|
|
34
|
+
expect(matchRoute(manifest, "/p/blue-runner-9f3a2b")).toEqual({
|
|
35
|
+
component: "app/p/[slug]/page",
|
|
36
|
+
params: { slug: "blue-runner-9f3a2b" },
|
|
37
|
+
});
|
|
38
|
+
expect(matchRoute(manifest, "/p/a%20b")).toEqual({
|
|
39
|
+
component: "app/p/[slug]/page",
|
|
40
|
+
params: { slug: "a b" },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("prefers a static route over a dynamic one at the same depth", () => {
|
|
45
|
+
expect(matchRoute(manifest, "/orders/new")).toEqual({
|
|
46
|
+
component: "app/orders/new/page",
|
|
47
|
+
params: {},
|
|
48
|
+
});
|
|
49
|
+
expect(matchRoute(manifest, "/orders/o_123")).toEqual({
|
|
50
|
+
component: "app/orders/[id]/page",
|
|
51
|
+
params: { id: "o_123" },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("matches a catch-all across the remaining segments", () => {
|
|
56
|
+
expect(matchRoute(manifest, "/docs/guide/getting-started")).toEqual({
|
|
57
|
+
component: "app/docs/[...rest]/page",
|
|
58
|
+
params: { rest: "guide/getting-started" },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores trailing slashes", () => {
|
|
63
|
+
expect(matchRoute(manifest, "/account/")).toEqual({
|
|
64
|
+
component: "app/account/page",
|
|
65
|
+
params: {},
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when nothing matches", () => {
|
|
70
|
+
expect(matchRoute(manifest, "/does/not/exist")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("never matches a boundary module (no path)", () => {
|
|
74
|
+
// /p/anything resolves to the page, never the not-found boundary.
|
|
75
|
+
const m = matchRoute(manifest, "/p/anything");
|
|
76
|
+
expect(m?.component).toBe("app/p/[slug]/page");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("is null-safe on an empty/absent manifest", () => {
|
|
80
|
+
expect(matchRoute(null, "/")).toBeNull();
|
|
81
|
+
expect(matchRoute({ routes: {} }, "/")).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Client-side route matcher for optimistic navigation.
|
|
2
|
+
//
|
|
3
|
+
// The SSR server owns the authoritative path→component mapping, but for
|
|
4
|
+
// optimistic navigation (painting a destination's Suspense fallback with a
|
|
5
|
+
// `<Link seed>` BEFORE the SSR fetch resolves) the client must resolve the
|
|
6
|
+
// destination route from the clicked href on its own. The build manifest ships
|
|
7
|
+
// each page route's `path` pattern (e.g. `/p/[slug]`) for exactly this — this
|
|
8
|
+
// module is the pure matcher over those patterns.
|
|
9
|
+
//
|
|
10
|
+
// Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so it has one
|
|
11
|
+
// source of truth and is unit-tested directly. The client bundler stages a copy
|
|
12
|
+
// next to the generated runtime, which imports it as `./route-match`.
|
|
13
|
+
|
|
14
|
+
export interface RouteMatch {
|
|
15
|
+
/** Route component path — the manifest key + __PYLON_DATA__.component value. */
|
|
16
|
+
component: string;
|
|
17
|
+
/** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The slice of the build manifest this matcher needs. */
|
|
22
|
+
export interface MatchableManifest {
|
|
23
|
+
routes: Record<string, { path?: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitPath(p: string): string[] {
|
|
27
|
+
const trimmed = p.replace(/\/+$/, "");
|
|
28
|
+
return trimmed === "" ? [] : trimmed.slice(1).split("/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SegMatch {
|
|
32
|
+
params: Record<string, string>;
|
|
33
|
+
/** Count of `[param]` segments — lower is more specific. */
|
|
34
|
+
dynamic: number;
|
|
35
|
+
/** Count of `[...rest]` catch-alls — dominates specificity. */
|
|
36
|
+
catchAll: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Match a route pattern's segments against a concrete path's segments. Handles
|
|
40
|
+
// static segments, `[param]` (single segment), and `[...param]` (catch-all,
|
|
41
|
+
// consumes the remainder). Returns null on any mismatch.
|
|
42
|
+
function matchSegments(
|
|
43
|
+
patSegs: string[],
|
|
44
|
+
targetSegs: string[],
|
|
45
|
+
): SegMatch | null {
|
|
46
|
+
const params: Record<string, string> = {};
|
|
47
|
+
let dynamic = 0;
|
|
48
|
+
for (let i = 0; i < patSegs.length; i++) {
|
|
49
|
+
const seg = patSegs[i];
|
|
50
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) {
|
|
51
|
+
const name = seg.slice(4, -1);
|
|
52
|
+
params[name] = targetSegs
|
|
53
|
+
.slice(i)
|
|
54
|
+
.map(safeDecode)
|
|
55
|
+
.join("/");
|
|
56
|
+
return { params, dynamic, catchAll: 1 };
|
|
57
|
+
}
|
|
58
|
+
if (i >= targetSegs.length) return null;
|
|
59
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
60
|
+
params[seg.slice(1, -1)] = safeDecode(targetSegs[i]);
|
|
61
|
+
dynamic++;
|
|
62
|
+
} else if (seg !== targetSegs[i]) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// No catch-all consumed the tail, so the lengths must line up exactly.
|
|
67
|
+
if (targetSegs.length !== patSegs.length) return null;
|
|
68
|
+
return { params, dynamic, catchAll: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeDecode(s: string): string {
|
|
72
|
+
try {
|
|
73
|
+
return decodeURIComponent(s);
|
|
74
|
+
} catch {
|
|
75
|
+
return s;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a concrete pathname to the route that would render it, plus its
|
|
81
|
+
* decoded dynamic params. Returns null when no page route matches (the caller
|
|
82
|
+
* then falls back to a normal server round-trip). When several patterns match,
|
|
83
|
+
* the most specific wins: fewer catch-alls first, then fewer dynamic segments —
|
|
84
|
+
* so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
|
|
85
|
+
*/
|
|
86
|
+
export function matchRoute(
|
|
87
|
+
manifest: MatchableManifest | null | undefined,
|
|
88
|
+
pathname: string,
|
|
89
|
+
): RouteMatch | null {
|
|
90
|
+
if (!manifest || !manifest.routes) return null;
|
|
91
|
+
const targetSegs = splitPath(pathname);
|
|
92
|
+
let best: { match: RouteMatch; score: number } | null = null;
|
|
93
|
+
for (const component of Object.keys(manifest.routes)) {
|
|
94
|
+
const route = manifest.routes[component];
|
|
95
|
+
if (!route || typeof route.path !== "string") continue;
|
|
96
|
+
const m = matchSegments(splitPath(route.path), targetSegs);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
const score = m.catchAll * 1000 + m.dynamic;
|
|
99
|
+
if (best === null || score < best.score) {
|
|
100
|
+
best = { match: { component, params: m.params }, score };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best ? best.match : null;
|
|
104
|
+
}
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -127,6 +127,54 @@ describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () =
|
|
|
127
127
|
expect(out2["x-pylon-cacheable"]).toBeUndefined();
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("finalizeHeaders: 3xx open-redirect guard on setHeader('location') / returned headers", () => {
|
|
131
|
+
const st = (status: number, location: string) => ({
|
|
132
|
+
status,
|
|
133
|
+
headers: { location } as Record<string, string>,
|
|
134
|
+
cookies: [] as string[],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// An off-site absolute Location on a 3xx (set via setHeader or a route
|
|
138
|
+
// handler's returned headers) is refused — the same rule redirect() applies.
|
|
139
|
+
expect(() => finalizeHeaders(st(302, "https://evil.example/steal"))).toThrow(
|
|
140
|
+
/open redirect/i,
|
|
141
|
+
);
|
|
142
|
+
// Protocol-relative `//host` is the classic bypass — also refused.
|
|
143
|
+
expect(() => finalizeHeaders(st(307, "//evil.example"))).toThrow(/open redirect/i);
|
|
144
|
+
// A backslash variant that browsers normalize cross-origin — refused.
|
|
145
|
+
expect(() => finalizeHeaders(st(303, "/\\evil.example"))).toThrow(/open redirect/i);
|
|
146
|
+
|
|
147
|
+
// A same-site relative path is fine and passes through unchanged.
|
|
148
|
+
expect(finalizeHeaders(st(302, "/dashboard"))["location"]).toBe("/dashboard");
|
|
149
|
+
|
|
150
|
+
// Case-insensitive header name is still caught (host lowercases, but guard
|
|
151
|
+
// must not depend on that).
|
|
152
|
+
expect(() =>
|
|
153
|
+
finalizeHeaders({
|
|
154
|
+
status: 302,
|
|
155
|
+
headers: { Location: "https://evil.example" } as Record<string, string>,
|
|
156
|
+
cookies: [],
|
|
157
|
+
}),
|
|
158
|
+
).toThrow(/open redirect/i);
|
|
159
|
+
|
|
160
|
+
// NON-3xx status → `location` is just a header, not a redirect: no guard.
|
|
161
|
+
expect(
|
|
162
|
+
finalizeHeaders(st(200, "https://evil.example"))["location"],
|
|
163
|
+
).toBe("https://evil.example");
|
|
164
|
+
|
|
165
|
+
// A raw `route.ts` GET returns its OWN status via the `effectiveStatus`
|
|
166
|
+
// arg — an off-site Location there is still refused even though
|
|
167
|
+
// `state.status` is 200.
|
|
168
|
+
expect(() =>
|
|
169
|
+
finalizeHeaders(
|
|
170
|
+
{ status: 200, headers: {}, cookies: [] },
|
|
171
|
+
{ location: "https://evil.example" },
|
|
172
|
+
undefined,
|
|
173
|
+
302,
|
|
174
|
+
),
|
|
175
|
+
).toThrow(/open redirect/i);
|
|
176
|
+
});
|
|
177
|
+
|
|
130
178
|
test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => {
|
|
131
179
|
const probes: Array<(o: any) => unknown> = [
|
|
132
180
|
(o) => o.host,
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -385,6 +385,10 @@ export function finalizeHeaders(
|
|
|
385
385
|
// TRUSTED runtime headers (e.g. the #277 `x-pylon-cacheable` proof). The ONLY
|
|
386
386
|
// legitimate source of `x-pylon-*`; merged last and never stripped.
|
|
387
387
|
internal?: Record<string, string>,
|
|
388
|
+
// The status this header map ships with, when it isn't `state.status` (a raw
|
|
389
|
+
// `route.ts` GET handler returns its own status). Used for the open-redirect
|
|
390
|
+
// guard below. Defaults to `state.status`.
|
|
391
|
+
effectiveStatus?: number,
|
|
388
392
|
): Record<string, string> {
|
|
389
393
|
// The host TRUSTS `x-pylon-*` headers (cache verdict, etc.). They must come
|
|
390
394
|
// ONLY from `internal` — strip them from BOTH page-set headers AND `extra` so
|
|
@@ -402,6 +406,34 @@ export function finalizeHeaders(
|
|
|
402
406
|
mergeStripped(state.headers);
|
|
403
407
|
mergeStripped(extra);
|
|
404
408
|
if (internal) Object.assign(h, internal); // trusted, never stripped
|
|
409
|
+
// SECURITY: open-redirect guard. A 3xx response's `Location` must be same-site
|
|
410
|
+
// or a trusted host — the SAME rule `response.redirect()` enforces — no matter
|
|
411
|
+
// how it was set: `response.setHeader("location", …)`, a `route.ts` handler's
|
|
412
|
+
// returned `headers`, or a `location` in `extra`. Those paths skip
|
|
413
|
+
// `redirect()`'s check, so without this a request-derived value becomes an
|
|
414
|
+
// off-site redirect. Fail loud (throws → 500/error boundary) rather than
|
|
415
|
+
// silently emitting the redirect. The `redirect()`/route-control paths pass an
|
|
416
|
+
// already-validated URL, so re-checking them here is a safe no-op.
|
|
417
|
+
const status = effectiveStatus ?? state.status;
|
|
418
|
+
if (status >= 300 && status < 400) {
|
|
419
|
+
const locKey = Object.keys(h).find((k) => k.toLowerCase() === "location");
|
|
420
|
+
if (locKey != null) {
|
|
421
|
+
const env = (globalThis as any).process?.env ?? {};
|
|
422
|
+
if (
|
|
423
|
+
!isSafeRedirect(h[locKey], {
|
|
424
|
+
publicUrl: env.PYLON_PUBLIC_URL,
|
|
425
|
+
canonicalHost: env.PYLON_CANONICAL_HOST,
|
|
426
|
+
trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
|
|
427
|
+
})
|
|
428
|
+
) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`pylon ssr: refused an untrusted redirect target ${JSON.stringify(h[locKey])} ` +
|
|
431
|
+
`in a ${status} response — use a same-site path (e.g. "/dashboard") or add the ` +
|
|
432
|
+
`host to PYLON_TRUSTED_HOSTS. (Refusing to emit an open redirect.)`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
405
437
|
if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
|
|
406
438
|
if (state.cookies.length > 0) {
|
|
407
439
|
// Preserve a set-cookie value set via setHeader() (rare) and join it
|
|
@@ -2397,8 +2429,12 @@ export async function handleRenderRoute(
|
|
|
2397
2429
|
// cached so `use()` doesn't re-suspend forever; resolved values land in
|
|
2398
2430
|
// `ssrValueCache` for hydration replay.
|
|
2399
2431
|
const { buildDbReader } = await import("./runtime");
|
|
2432
|
+
// `ssrRead: true` — serverData results are serialized into the
|
|
2433
|
+
// client-visible `__PYLON_DATA__` blob, so the host applies the same
|
|
2434
|
+
// per-row policy filter + `server_only`/`passwordHash` projection the
|
|
2435
|
+
// entity/sync read API does. `serverData.unsafe.*` stays server-trust.
|
|
2400
2436
|
const serverData = makeServerData(
|
|
2401
|
-
buildDbReader(msg.call_id),
|
|
2437
|
+
buildDbReader(msg.call_id, true),
|
|
2402
2438
|
ssrValueCache,
|
|
2403
2439
|
);
|
|
2404
2440
|
|