@pylonsync/functions 0.3.259 → 0.3.262
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 +46 -40
- package/src/ssr-client-bundler.ts +19 -1
- 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 {
|
|
@@ -942,12 +932,12 @@ async function main() {
|
|
|
942
932
|
(f) => f.endsWith(".ts") || f.endsWith(".js")
|
|
943
933
|
);
|
|
944
934
|
} catch {
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
935
|
+
// No `functions/` directory. Legitimate for a pure-SSR app (file-based
|
|
936
|
+
// `app/**/page.tsx` routes + entity CRUD, no server functions) — the host
|
|
937
|
+
// still spawns this runner to execute SSR renders. Load zero functions and
|
|
938
|
+
// fall through so we send `ready` AND start the reader loop; returning here
|
|
939
|
+
// would leave the runner unable to serve renders (silent 404s).
|
|
940
|
+
files = [];
|
|
951
941
|
}
|
|
952
942
|
|
|
953
943
|
for (const file of files) {
|
|
@@ -989,7 +979,23 @@ async function main() {
|
|
|
989
979
|
}));
|
|
990
980
|
send({ type: "ready", functions });
|
|
991
981
|
|
|
982
|
+
// Belt-and-suspenders against orphaning: if the host dies in a way that
|
|
983
|
+
// somehow leaves our stdin open, we'll have been reparented to init
|
|
984
|
+
// (ppid === 1). Notice and exit. Unref'd so it never keeps us alive on its
|
|
985
|
+
// own.
|
|
986
|
+
const orphanWatch = setInterval(() => {
|
|
987
|
+
if (process.ppid === 1) process.exit(0);
|
|
988
|
+
}, 2000);
|
|
989
|
+
if (typeof orphanWatch.unref === "function") orphanWatch.unref();
|
|
990
|
+
|
|
992
991
|
await readerLoop();
|
|
992
|
+
|
|
993
|
+
// readerLoop only returns when stdin hits EOF — i.e. the host (the pylon
|
|
994
|
+
// process that spawned us) is gone. Force-exit. We must NOT rely on the
|
|
995
|
+
// event loop draining on its own: the stdout writer, keep-alive sockets
|
|
996
|
+
// from `fetch`, and Bun's own handles keep the process alive, so every
|
|
997
|
+
// killed `pylon dev` would otherwise orphan its whole bun runner pool.
|
|
998
|
+
process.exit(0);
|
|
993
999
|
}
|
|
994
1000
|
|
|
995
1001
|
main().catch((err) => {
|
|
@@ -290,6 +290,15 @@ function makeNoopResponse() {
|
|
|
290
290
|
// For a hydrated error boundary (#279), synthesize the reset() the server
|
|
291
291
|
// rendered as a no-op: re-fetch + re-render the current URL (a transient
|
|
292
292
|
// error clears to the page; a deterministic one re-shows the boundary).
|
|
293
|
+
// The current route's dynamic params (e.g. { projectId: "p_1" }). Lives here,
|
|
294
|
+
// not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()
|
|
295
|
+
// in a deep client child has a reactive source. A fresh object per nav → stable
|
|
296
|
+
// reference between navs (useSyncExternalStore needs that).
|
|
297
|
+
let currentParams = {};
|
|
298
|
+
function setNavParams(data) {
|
|
299
|
+
currentParams = (data && data.props && data.props.params) || {};
|
|
300
|
+
}
|
|
301
|
+
|
|
293
302
|
function withClientProps(data) {
|
|
294
303
|
const props = { ...(data.props || {}) };
|
|
295
304
|
props.serverData = makeClientServerData(data.ssrData);
|
|
@@ -396,6 +405,7 @@ export function hydrate(component, Page, Layouts) {
|
|
|
396
405
|
);
|
|
397
406
|
return;
|
|
398
407
|
}
|
|
408
|
+
setNavParams(data);
|
|
399
409
|
const tree = buildTree(Page, Layouts, withClientProps(data));
|
|
400
410
|
activeRoot = hydrateRoot(document, tree);
|
|
401
411
|
installNavHandlers();
|
|
@@ -472,6 +482,7 @@ async function navigate(href, opts) {
|
|
|
472
482
|
}
|
|
473
483
|
document.title = doc.title || document.title;
|
|
474
484
|
syncHeadMeta(doc);
|
|
485
|
+
setNavParams(data);
|
|
475
486
|
const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
|
|
476
487
|
activeRoot.render(tree);
|
|
477
488
|
const target = url.pathname + url.search;
|
|
@@ -559,7 +570,14 @@ function installNavHandlers() {
|
|
|
559
570
|
}
|
|
560
571
|
|
|
561
572
|
// Expose for <Link> component prefetch.
|
|
562
|
-
const pylonGlobal = {
|
|
573
|
+
const pylonGlobal = {
|
|
574
|
+
prefetch,
|
|
575
|
+
navigate,
|
|
576
|
+
// Read by useParams(); a getter so it always reflects the latest nav.
|
|
577
|
+
get params() {
|
|
578
|
+
return currentParams;
|
|
579
|
+
},
|
|
580
|
+
};
|
|
563
581
|
if (typeof window !== "undefined") {
|
|
564
582
|
window.__pylon = pylonGlobal;
|
|
565
583
|
}
|
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>;
|