@pylonsync/functions 0.3.261 → 0.3.263
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/package.json +1 -1
- package/src/runtime-db.test.ts +86 -0
- package/src/runtime.ts +24 -34
- package/src/ssr-client-bundler.ts +61 -18
- package/src/ssr-runtime.test.ts +40 -0
- package/src/ssr-runtime.ts +45 -11
- package/src/types.ts +5 -4
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the ctx.db builder surfaces (runtime.ts).
|
|
3
|
+
*
|
|
4
|
+
* Guards the contract the types now promise (types.ts made `unsafe`
|
|
5
|
+
* REQUIRED on the top-level db and absent on the unsafe surface):
|
|
6
|
+
*
|
|
7
|
+
* 1. `db.unsafe` always exists, with the full op surface.
|
|
8
|
+
* 2. `db.unsafe.unsafe` does NOT exist (no chaining — it would be a
|
|
9
|
+
* runtime undefined that the old optional type silently allowed).
|
|
10
|
+
* 3. Plain ops emit `unsafe_op: false`; `unsafe.*` ops emit
|
|
11
|
+
* `unsafe_op: true` — the flag the Rust policy gate keys on.
|
|
12
|
+
*
|
|
13
|
+
* runtime.ts runs main() on import (it IS the bun runner entrypoint),
|
|
14
|
+
* so the builders are exercised in a child process: we feed it a
|
|
15
|
+
* script over a kept-open stdin pipe and read the NDJSON protocol
|
|
16
|
+
* frames it writes to stdout.
|
|
17
|
+
*/
|
|
18
|
+
import { expect, test } from "bun:test";
|
|
19
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
24
|
+
|
|
25
|
+
const SCRIPT = `
|
|
26
|
+
import { buildDbReader, buildDbWriter } from ${JSON.stringify(RUNTIME)};
|
|
27
|
+
|
|
28
|
+
const reader = buildDbReader("c_r");
|
|
29
|
+
const writer = buildDbWriter("c_w");
|
|
30
|
+
|
|
31
|
+
// Structural facts ride stderr (fenceStdout reserves stdout for frames).
|
|
32
|
+
console.error("STRUCT " + JSON.stringify({
|
|
33
|
+
readerUnsafe: typeof reader.unsafe.get === "function",
|
|
34
|
+
writerUnsafeWrites: typeof (writer.unsafe as any).update === "function",
|
|
35
|
+
noReaderChain: (reader.unsafe as any).unsafe === undefined,
|
|
36
|
+
noWriterChain: (writer.unsafe as any).unsafe === undefined,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Fire one op per surface — no host replies, so don't await; the
|
|
40
|
+
// emitted frames on stdout are the assertion target.
|
|
41
|
+
reader.get("Todo", "r1").catch(() => {});
|
|
42
|
+
reader.unsafe.get("Todo", "r2").catch(() => {});
|
|
43
|
+
writer.update("Todo", "w1", { a: 1 }).catch(() => {});
|
|
44
|
+
writer.unsafe.update("Todo", "w2", { a: 2 }).catch(() => {});
|
|
45
|
+
|
|
46
|
+
setTimeout(() => process.exit(0), 300);
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
test("db builders: required unsafe surface, no chaining, unsafe_op flag routing", async () => {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-db-"));
|
|
51
|
+
const scriptPath = join(dir, "probe.ts");
|
|
52
|
+
writeFileSync(scriptPath, SCRIPT);
|
|
53
|
+
|
|
54
|
+
const proc = Bun.spawn(["bun", scriptPath], {
|
|
55
|
+
stdin: "pipe", // keep main()'s readerLoop alive until the probe exits itself
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
});
|
|
59
|
+
const [stdout, stderr] = await Promise.all([
|
|
60
|
+
new Response(proc.stdout).text(),
|
|
61
|
+
new Response(proc.stderr).text(),
|
|
62
|
+
proc.exited,
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// 1+2: structure as reported from inside the child.
|
|
66
|
+
const structLine = stderr.split("\n").find((l) => l.includes("STRUCT "));
|
|
67
|
+
expect(structLine).toBeDefined();
|
|
68
|
+
const struct = JSON.parse(structLine!.slice(structLine!.indexOf("STRUCT ") + 7));
|
|
69
|
+
expect(struct.readerUnsafe).toBe(true);
|
|
70
|
+
expect(struct.writerUnsafeWrites).toBe(true);
|
|
71
|
+
expect(struct.noReaderChain).toBe(true);
|
|
72
|
+
expect(struct.noWriterChain).toBe(true);
|
|
73
|
+
|
|
74
|
+
// 3: every emitted db frame carries the right unsafe_op flag.
|
|
75
|
+
const frames = stdout
|
|
76
|
+
.split("\n")
|
|
77
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
78
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
79
|
+
.filter((f) => f.type === "db");
|
|
80
|
+
const byId = (id: string) => frames.find((f) => f.id === id);
|
|
81
|
+
|
|
82
|
+
expect(byId("r1")).toMatchObject({ op: "get", unsafe_op: false });
|
|
83
|
+
expect(byId("r2")).toMatchObject({ op: "get", unsafe_op: true });
|
|
84
|
+
expect(byId("w1")).toMatchObject({ op: "update", unsafe_op: false });
|
|
85
|
+
expect(byId("w2")).toMatchObject({ op: "update", unsafe_op: true });
|
|
86
|
+
});
|
package/src/runtime.ts
CHANGED
|
@@ -357,7 +357,11 @@ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
|
|
|
357
357
|
// `serverData` read handle that reuses this module's `send` + `pendingRpcs`
|
|
358
358
|
// + reader loop. The render call_id ("r_<n>") correlates DB replies back
|
|
359
359
|
// through the shared pendingRpcs map.
|
|
360
|
-
export function buildDbReader(callId: string
|
|
360
|
+
export function buildDbReader(callId: string): DbReader {
|
|
361
|
+
return { ...buildReaderOps(callId, false), unsafe: buildReaderOps(callId, true) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsafe"> {
|
|
361
365
|
// All DB ops use rpcDb so Promise.all over ctx.db reads can run in
|
|
362
366
|
// parallel without colliding on the outer call_id key.
|
|
363
367
|
//
|
|
@@ -366,7 +370,7 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
|
|
|
366
370
|
// caller-aware policy gate (in Phase 2 — see
|
|
367
371
|
// pylon-functions/protocol.rs). Plain ctx.db.* leaves the flag
|
|
368
372
|
// off (the safe default); ctx.db.unsafe.* sets it.
|
|
369
|
-
|
|
373
|
+
return {
|
|
370
374
|
async get(entity, id) {
|
|
371
375
|
return (await rpcDb(callId, {
|
|
372
376
|
type: "db",
|
|
@@ -435,23 +439,26 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
|
|
|
435
439
|
})) as any;
|
|
436
440
|
},
|
|
437
441
|
};
|
|
438
|
-
if (!unsafeOp) {
|
|
439
|
-
(reader as DbReader & { unsafe: DbReader }).unsafe = buildDbReader(
|
|
440
|
-
callId,
|
|
441
|
-
true,
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
return reader;
|
|
445
442
|
}
|
|
446
443
|
|
|
447
|
-
export function buildDbWriter(callId: string
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
444
|
+
export function buildDbWriter(callId: string): DbWriter {
|
|
445
|
+
// Top-level `ctx.db` is the safe path. `ctx.db.unsafe` is the
|
|
446
|
+
// escape hatch — same surface, every emitted op carries
|
|
447
|
+
// `unsafe_op: true` so the future caller-aware policy gate
|
|
448
|
+
// (PYLON_STRICT_FN_POLICIES) skips enforcement. Use sparingly,
|
|
449
|
+
// with a justifying comment, ideally in code that runs only
|
|
450
|
+
// from server-internal callers (webhooks, cron sweeps, admin
|
|
451
|
+
// tools).
|
|
452
|
+
//
|
|
453
|
+
// The unsafe surface carries no `.unsafe` of its own — chaining
|
|
454
|
+
// is a compile error AND a self-reference would loop on JSON
|
|
455
|
+
// serialization.
|
|
456
|
+
return { ...buildWriterOps(callId, false), unsafe: buildWriterOps(callId, true) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function buildWriterOps(callId: string, unsafeOp: boolean): Omit<DbWriter, "unsafe"> {
|
|
460
|
+
return {
|
|
461
|
+
...buildReaderOps(callId, unsafeOp),
|
|
455
462
|
async insert(entity, data) {
|
|
456
463
|
const r = (await rpcDb(callId, {
|
|
457
464
|
type: "db",
|
|
@@ -518,23 +525,6 @@ export function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
|
|
|
518
525
|
});
|
|
519
526
|
},
|
|
520
527
|
};
|
|
521
|
-
// Top-level `ctx.db` is the safe path. `ctx.db.unsafe` is the
|
|
522
|
-
// escape hatch — same surface, every emitted op carries
|
|
523
|
-
// `unsafe_op: true` so the future caller-aware policy gate
|
|
524
|
-
// (PYLON_STRICT_FN_POLICIES) skips enforcement. Use sparingly,
|
|
525
|
-
// with a justifying comment, ideally in code that runs only
|
|
526
|
-
// from server-internal callers (webhooks, cron sweeps, admin
|
|
527
|
-
// tools).
|
|
528
|
-
//
|
|
529
|
-
// Self-reference would create an infinite loop on JSON
|
|
530
|
-
// serialization; only assign on the writer's `.unsafe` once.
|
|
531
|
-
if (!unsafeOp) {
|
|
532
|
-
(writer as DbWriter & { unsafe: DbWriter }).unsafe = buildDbWriter(
|
|
533
|
-
callId,
|
|
534
|
-
true,
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
return writer;
|
|
538
528
|
}
|
|
539
529
|
|
|
540
530
|
function buildStream(callId: string): Stream {
|
|
@@ -38,6 +38,16 @@ type Send = (msg: Record<string, unknown>) => void;
|
|
|
38
38
|
interface BundleClientMessage {
|
|
39
39
|
type: "bundle_client";
|
|
40
40
|
call_id: string;
|
|
41
|
+
/**
|
|
42
|
+
* Project-relative directory holding the route tree
|
|
43
|
+
* (`<app_dir>/**/page.tsx`). Defaults to `"app"` when the host
|
|
44
|
+
* doesn't send it (older hosts, or the default single-`app/`
|
|
45
|
+
* layout). The full-stack app that namespaces its frontend under a
|
|
46
|
+
* subdir — e.g. `web/app` via `discoverAppRoutes({appDir:"web/app"})`
|
|
47
|
+
* — sends the same dir here so the client bundler and the SSR
|
|
48
|
+
* manifest agree on where the routes live.
|
|
49
|
+
*/
|
|
50
|
+
app_dir?: string;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
interface DiscoveredRoute {
|
|
@@ -87,19 +97,22 @@ declare const Bun: {
|
|
|
87
97
|
};
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
|
-
* Synchronously walk
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* `
|
|
94
|
-
* so the
|
|
95
|
-
* field.
|
|
100
|
+
* Synchronously walk the route dir (`<appDirRel>` under cwd, e.g.
|
|
101
|
+
* `app` or `web/app`) and return one entry per discovered page, each
|
|
102
|
+
* carrying its layout chain (root → leaf). `appDirRel` MUST match the
|
|
103
|
+
* `appDir` the manifest was built with (`discoverAppRoutes({appDir})`)
|
|
104
|
+
* so the component paths — `path.relative(cwd, file)` — line up with
|
|
105
|
+
* the manifest's `component` field byte-for-byte. Mirrors the
|
|
106
|
+
* discovery logic in @pylonsync/sdk's `discoverAppRoutes` exactly:
|
|
107
|
+
* same sort order, same group-strip.
|
|
96
108
|
*/
|
|
97
109
|
function discoverRoutes(
|
|
98
110
|
fs: any,
|
|
99
111
|
path: any,
|
|
100
112
|
cwd: string,
|
|
113
|
+
appDirRel: string,
|
|
101
114
|
): DiscoveredRoute[] {
|
|
102
|
-
const appDir = path.join(cwd,
|
|
115
|
+
const appDir = path.join(cwd, appDirRel);
|
|
103
116
|
if (!fs.existsSync(appDir) || !fs.statSync(appDir).isDirectory()) {
|
|
104
117
|
return [];
|
|
105
118
|
}
|
|
@@ -290,6 +303,15 @@ function makeNoopResponse() {
|
|
|
290
303
|
// For a hydrated error boundary (#279), synthesize the reset() the server
|
|
291
304
|
// rendered as a no-op: re-fetch + re-render the current URL (a transient
|
|
292
305
|
// error clears to the page; a deterministic one re-shows the boundary).
|
|
306
|
+
// The current route's dynamic params (e.g. { projectId: "p_1" }). Lives here,
|
|
307
|
+
// not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()
|
|
308
|
+
// in a deep client child has a reactive source. A fresh object per nav → stable
|
|
309
|
+
// reference between navs (useSyncExternalStore needs that).
|
|
310
|
+
let currentParams = {};
|
|
311
|
+
function setNavParams(data) {
|
|
312
|
+
currentParams = (data && data.props && data.props.params) || {};
|
|
313
|
+
}
|
|
314
|
+
|
|
293
315
|
function withClientProps(data) {
|
|
294
316
|
const props = { ...(data.props || {}) };
|
|
295
317
|
props.serverData = makeClientServerData(data.ssrData);
|
|
@@ -396,6 +418,7 @@ export function hydrate(component, Page, Layouts) {
|
|
|
396
418
|
);
|
|
397
419
|
return;
|
|
398
420
|
}
|
|
421
|
+
setNavParams(data);
|
|
399
422
|
const tree = buildTree(Page, Layouts, withClientProps(data));
|
|
400
423
|
activeRoot = hydrateRoot(document, tree);
|
|
401
424
|
installNavHandlers();
|
|
@@ -472,6 +495,7 @@ async function navigate(href, opts) {
|
|
|
472
495
|
}
|
|
473
496
|
document.title = doc.title || document.title;
|
|
474
497
|
syncHeadMeta(doc);
|
|
498
|
+
setNavParams(data);
|
|
475
499
|
const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
|
|
476
500
|
activeRoot.render(tree);
|
|
477
501
|
const target = url.pathname + url.search;
|
|
@@ -559,7 +583,14 @@ function installNavHandlers() {
|
|
|
559
583
|
}
|
|
560
584
|
|
|
561
585
|
// Expose for <Link> component prefetch.
|
|
562
|
-
const pylonGlobal = {
|
|
586
|
+
const pylonGlobal = {
|
|
587
|
+
prefetch,
|
|
588
|
+
navigate,
|
|
589
|
+
// Read by useParams(); a getter so it always reflects the latest nav.
|
|
590
|
+
get params() {
|
|
591
|
+
return currentParams;
|
|
592
|
+
},
|
|
593
|
+
};
|
|
563
594
|
if (typeof window !== "undefined") {
|
|
564
595
|
window.__pylon = pylonGlobal;
|
|
565
596
|
}
|
|
@@ -666,11 +697,13 @@ let _inflightBuild: Promise<BuildOutput> | null = null;
|
|
|
666
697
|
* Used from `handleBundleClient` (protocol RPC path from Rust) AND
|
|
667
698
|
* from `getManifest` (in-process SSR path).
|
|
668
699
|
*/
|
|
669
|
-
export async function buildClientBundle(
|
|
700
|
+
export async function buildClientBundle(
|
|
701
|
+
appDirRel: string = "app",
|
|
702
|
+
): Promise<BuildOutput> {
|
|
670
703
|
if (_inflightBuild) return _inflightBuild;
|
|
671
704
|
_inflightBuild = (async () => {
|
|
672
705
|
try {
|
|
673
|
-
return await _doBuild();
|
|
706
|
+
return await _doBuild(appDirRel);
|
|
674
707
|
} finally {
|
|
675
708
|
_inflightBuild = null;
|
|
676
709
|
}
|
|
@@ -678,7 +711,7 @@ export async function buildClientBundle(): Promise<BuildOutput> {
|
|
|
678
711
|
return _inflightBuild;
|
|
679
712
|
}
|
|
680
713
|
|
|
681
|
-
async function _doBuild(): Promise<BuildOutput> {
|
|
714
|
+
async function _doBuild(appDirRel: string): Promise<BuildOutput> {
|
|
682
715
|
// node:* are available in Bun, but `globalThis.require` is
|
|
683
716
|
// not defined in ESM. Use dynamic import; Bun fast-paths these.
|
|
684
717
|
const fsMod: any = await import("node:fs");
|
|
@@ -686,7 +719,7 @@ async function _doBuild(): Promise<BuildOutput> {
|
|
|
686
719
|
const fs = fsMod.default ?? fsMod;
|
|
687
720
|
const path = pathMod.default ?? pathMod;
|
|
688
721
|
const cwd = process.cwd();
|
|
689
|
-
return _doBuildInner(fs, path, cwd);
|
|
722
|
+
return _doBuildInner(fs, path, cwd, appDirRel);
|
|
690
723
|
}
|
|
691
724
|
|
|
692
725
|
/**
|
|
@@ -701,8 +734,9 @@ async function buildTailwind(
|
|
|
701
734
|
path: any,
|
|
702
735
|
cwd: string,
|
|
703
736
|
outdir: string,
|
|
737
|
+
appDirRel: string,
|
|
704
738
|
): Promise<string | null> {
|
|
705
|
-
const globalsPath = path.join(cwd,
|
|
739
|
+
const globalsPath = path.join(cwd, appDirRel, "globals.css");
|
|
706
740
|
if (!fs.existsSync(globalsPath)) return null;
|
|
707
741
|
// Resolve @tailwindcss/cli. The package only exports
|
|
708
742
|
// `./package.json` in its `exports` map (it's a binary, not a
|
|
@@ -760,10 +794,17 @@ async function buildTailwind(
|
|
|
760
794
|
return stylesName;
|
|
761
795
|
}
|
|
762
796
|
|
|
763
|
-
async function _doBuildInner(
|
|
764
|
-
|
|
797
|
+
async function _doBuildInner(
|
|
798
|
+
fs: any,
|
|
799
|
+
path: any,
|
|
800
|
+
cwd: string,
|
|
801
|
+
appDirRel: string,
|
|
802
|
+
): Promise<BuildOutput> {
|
|
803
|
+
const routes = discoverRoutes(fs, path, cwd, appDirRel);
|
|
765
804
|
if (routes.length === 0) {
|
|
766
|
-
throw new Error(
|
|
805
|
+
throw new Error(
|
|
806
|
+
`no SSR routes discovered under ${appDirRel}/ — nothing to bundle`,
|
|
807
|
+
);
|
|
767
808
|
}
|
|
768
809
|
|
|
769
810
|
const stageDir = path.join(cwd, ".pylon");
|
|
@@ -958,7 +999,7 @@ async function _doBuildInner(fs: any, path: any, cwd: string): Promise<BuildOutp
|
|
|
958
999
|
// `app/globals.css`. Adds the stylesheet to every route's css
|
|
959
1000
|
// array so SSR head injection emits `<link rel="stylesheet">`.
|
|
960
1001
|
try {
|
|
961
|
-
const styles = await buildTailwind(fs, path, cwd, outdir);
|
|
1002
|
+
const styles = await buildTailwind(fs, path, cwd, outdir, appDirRel);
|
|
962
1003
|
if (styles) {
|
|
963
1004
|
for (const r of Object.values(manifest.routes)) {
|
|
964
1005
|
r.css = [styles];
|
|
@@ -1036,7 +1077,9 @@ export async function handleBundleClient(
|
|
|
1036
1077
|
send: Send,
|
|
1037
1078
|
): Promise<void> {
|
|
1038
1079
|
try {
|
|
1039
|
-
const
|
|
1080
|
+
const appDirRel =
|
|
1081
|
+
msg.app_dir && msg.app_dir.length > 0 ? msg.app_dir : "app";
|
|
1082
|
+
const { manifestPath, outdir } = await buildClientBundle(appDirRel);
|
|
1040
1083
|
send({
|
|
1041
1084
|
type: "bundle_client_result",
|
|
1042
1085
|
call_id: msg.call_id,
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
buildHydrationTail,
|
|
12
12
|
errorDigest,
|
|
13
13
|
resolveOrigin,
|
|
14
|
+
asRouteControl,
|
|
15
|
+
PylonRouteControl,
|
|
14
16
|
} from "./ssr-runtime";
|
|
15
17
|
|
|
16
18
|
describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
|
|
@@ -391,3 +393,41 @@ describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () =
|
|
|
391
393
|
expect(errorDigest(new Error("other"))).not.toBe(d1);
|
|
392
394
|
});
|
|
393
395
|
});
|
|
396
|
+
|
|
397
|
+
describe("asRouteControl — route-control normalization (redirect/notFound)", () => {
|
|
398
|
+
test("passes the framework's own PylonRouteControl straight through", () => {
|
|
399
|
+
const redirect = new PylonRouteControl("redirect");
|
|
400
|
+
redirect.url = "/login";
|
|
401
|
+
redirect.redirectStatus = 302;
|
|
402
|
+
expect(asRouteControl(redirect)).toBe(redirect);
|
|
403
|
+
|
|
404
|
+
const nf = new PylonRouteControl("notFound");
|
|
405
|
+
expect(asRouteControl(nf)).toBe(nf);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("recognizes @pylonsync/react's branded notFound() error by digest", () => {
|
|
409
|
+
// The cross-package contract: `notFound()` from @pylonsync/react throws an
|
|
410
|
+
// error stamped `digest === "PYLON_NOT_FOUND"`. The runtime duck-types on
|
|
411
|
+
// that brand (no import of the React class) and turns it into a notFound
|
|
412
|
+
// control → a real 404 + nearest not-found.tsx. If this regresses, a
|
|
413
|
+
// server page calling notFound() would 500 instead of 404.
|
|
414
|
+
const reactNotFound = Object.assign(new Error("PYLON_NOT_FOUND"), {
|
|
415
|
+
digest: "PYLON_NOT_FOUND",
|
|
416
|
+
});
|
|
417
|
+
const ctrl = asRouteControl(reactNotFound);
|
|
418
|
+
expect(ctrl).not.toBeNull();
|
|
419
|
+
expect(ctrl?.kind).toBe("notFound");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("does NOT swallow ordinary errors as a 404 (fails open is forbidden)", () => {
|
|
423
|
+
// The critical safety property: a real render error must fall through to
|
|
424
|
+
// the error.tsx / 500 path, never be silently masked as a not-found.
|
|
425
|
+
expect(asRouteControl(new Error("boom"))).toBeNull();
|
|
426
|
+
expect(asRouteControl(new TypeError("nope"))).toBeNull();
|
|
427
|
+
expect(asRouteControl({ digest: "SOME_OTHER_DIGEST" })).toBeNull();
|
|
428
|
+
expect(asRouteControl({ digest: 42 })).toBeNull();
|
|
429
|
+
expect(asRouteControl(null)).toBeNull();
|
|
430
|
+
expect(asRouteControl(undefined)).toBeNull();
|
|
431
|
+
expect(asRouteControl("PYLON_NOT_FOUND")).toBeNull(); // a bare string, not an error
|
|
432
|
+
});
|
|
433
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -99,6 +99,34 @@ export class PylonRouteControl extends Error {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Digest brand that `@pylonsync/react`'s `notFound()` stamps on the error it
|
|
104
|
+
* throws. We recognize it here by string instead of importing the class so the
|
|
105
|
+
* runtime stays decoupled from the React package (which doesn't depend on
|
|
106
|
+
* functions). Keep in sync with `NotFoundError.digest` in
|
|
107
|
+
* `packages/react/src/useRouter.ts`.
|
|
108
|
+
*/
|
|
109
|
+
const REACT_NOT_FOUND_DIGEST = "PYLON_NOT_FOUND";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a thrown value into a route-control signal: either the framework's
|
|
113
|
+
* own `PylonRouteControl` (`response.redirect()` / `response.notFound()`) or a
|
|
114
|
+
* branded `NotFoundError` thrown by `@pylonsync/react`'s `notFound()` from a
|
|
115
|
+
* page/layout render. Returns `null` for an ordinary error so the caller falls
|
|
116
|
+
* through to its real error-handling path.
|
|
117
|
+
*/
|
|
118
|
+
export function asRouteControl(err: unknown): PylonRouteControl | null {
|
|
119
|
+
if (err instanceof PylonRouteControl) return err;
|
|
120
|
+
if (
|
|
121
|
+
err != null &&
|
|
122
|
+
typeof err === "object" &&
|
|
123
|
+
(err as { digest?: unknown }).digest === REACT_NOT_FOUND_DIGEST
|
|
124
|
+
) {
|
|
125
|
+
return new PylonRouteControl("notFound");
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
export interface SsrCookieOptions {
|
|
103
131
|
path?: string;
|
|
104
132
|
domain?: string;
|
|
@@ -1481,7 +1509,8 @@ export async function handleRenderRoute(
|
|
|
1481
1509
|
// (partial HTML); the dev overlay (#275) only covers failures BEFORE
|
|
1482
1510
|
// response_start (host-side err channel). Buffered renders surface
|
|
1483
1511
|
// their error through the catch/boundary path below.
|
|
1484
|
-
|
|
1512
|
+
const ctrl = asRouteControl(err);
|
|
1513
|
+
if (ctrl) {
|
|
1485
1514
|
// A redirect()/notFound() thrown from BELOW a <Suspense> boundary:
|
|
1486
1515
|
// the shell already committed the head, so React swallowed it and
|
|
1487
1516
|
// it can't change the response. This is a known limitation on BOTH
|
|
@@ -1489,9 +1518,10 @@ export async function handleRenderRoute(
|
|
|
1489
1518
|
// synchronous shell). Surface it loudly instead of silently losing.
|
|
1490
1519
|
// eslint-disable-next-line no-console
|
|
1491
1520
|
console.error(
|
|
1492
|
-
`[ssr]
|
|
1493
|
-
`the HTTP head was already sent. Call response.redirect()/notFound()
|
|
1494
|
-
`synchronous shell render, before
|
|
1521
|
+
`[ssr] ${ctrl.kind}() called below a <Suspense> boundary was ignored — ` +
|
|
1522
|
+
`the HTTP head was already sent. Call response.redirect()/notFound() (or ` +
|
|
1523
|
+
`notFound() from @pylonsync/react) in the synchronous shell render, before ` +
|
|
1524
|
+
`any await/<Suspense>.`,
|
|
1495
1525
|
);
|
|
1496
1526
|
return;
|
|
1497
1527
|
}
|
|
@@ -1736,16 +1766,20 @@ export async function handleRenderRoute(
|
|
|
1736
1766
|
|
|
1737
1767
|
send({ type: "render_done", call_id: msg.call_id });
|
|
1738
1768
|
} catch (err: any) {
|
|
1739
|
-
// A page/layout called response.redirect()
|
|
1740
|
-
// during render → short-circuit to a
|
|
1741
|
-
// of a body. Page-set cookies/headers
|
|
1742
|
-
|
|
1743
|
-
|
|
1769
|
+
// A page/layout called response.redirect()/response.notFound(), or
|
|
1770
|
+
// `notFound()` from @pylonsync/react, during render → short-circuit to a
|
|
1771
|
+
// 3xx + Location or a 404 instead of a body. Page-set cookies/headers
|
|
1772
|
+
// still ride along.
|
|
1773
|
+
const ctrl = asRouteControl(err);
|
|
1774
|
+
if (ctrl) {
|
|
1775
|
+
if (ctrl.kind === "redirect") {
|
|
1744
1776
|
send({
|
|
1745
1777
|
type: "response_start",
|
|
1746
1778
|
call_id: msg.call_id,
|
|
1747
|
-
status:
|
|
1748
|
-
headers: finalizeHeaders(responseState, {
|
|
1779
|
+
status: ctrl.redirectStatus ?? 307,
|
|
1780
|
+
headers: finalizeHeaders(responseState, {
|
|
1781
|
+
location: ctrl.url ?? "/",
|
|
1782
|
+
}),
|
|
1749
1783
|
});
|
|
1750
1784
|
send({ type: "render_done", call_id: msg.call_id });
|
|
1751
1785
|
return;
|
package/src/types.ts
CHANGED
|
@@ -99,10 +99,11 @@ export interface DbReader {
|
|
|
99
99
|
* convention. A future `pylon lint` rule will flag bare
|
|
100
100
|
* `ctx.db.unsafe.*` without a comment immediately above.
|
|
101
101
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
102
|
+
* Required on the type (every runtime since v0.3.161 ships it) —
|
|
103
|
+
* but absent on the unsafe surface itself, so `ctx.db.unsafe.unsafe`
|
|
104
|
+
* is a compile error rather than a runtime undefined.
|
|
104
105
|
*/
|
|
105
|
-
unsafe
|
|
106
|
+
unsafe: Omit<DbReader, "unsafe">;
|
|
106
107
|
|
|
107
108
|
/** Get a single row by ID. Returns null if not found. */
|
|
108
109
|
get(entity: string, id: string): Promise<Record<string, unknown> | null>;
|
|
@@ -201,7 +202,7 @@ export interface DbWriter extends DbReader {
|
|
|
201
202
|
* write surface (insert/update/delete/link/unlink/advisoryLock).
|
|
202
203
|
* Overrides the inherited read-only `unsafe` from DbReader.
|
|
203
204
|
*/
|
|
204
|
-
unsafe
|
|
205
|
+
unsafe: Omit<DbWriter, "unsafe">;
|
|
205
206
|
|
|
206
207
|
/** Insert a new row. Returns the generated ID. */
|
|
207
208
|
insert(entity: string, data: Record<string, unknown>): Promise<string>;
|