@pylonsync/functions 0.3.236 → 0.3.238

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.236",
3
+ "version": "0.3.238",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/runtime.ts CHANGED
@@ -329,7 +329,11 @@ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
329
329
  // Context builders
330
330
  // ---------------------------------------------------------------------------
331
331
 
332
- function buildDbReader(callId: string, unsafeOp = false): DbReader {
332
+ // Exported so the SSR runtime (ssr-runtime.ts) can build a page-facing
333
+ // `serverData` read handle that reuses this module's `send` + `pendingRpcs`
334
+ // + reader loop. The render call_id ("r_<n>") correlates DB replies back
335
+ // through the shared pendingRpcs map.
336
+ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
333
337
  // All DB ops use rpcDb so Promise.all over ctx.db reads can run in
334
338
  // parallel without colliding on the outer call_id key.
335
339
  //
@@ -192,6 +192,92 @@ function buildTree(Page, Layouts, props) {
192
192
  return tree;
193
193
  }
194
194
 
195
+ // Deterministic stringify — MUST match stableStringify in ssr-runtime.ts so
196
+ // a serverData call's cache key is identical on server and client.
197
+ function stableStringify(v) {
198
+ if (v === null || v === undefined || typeof v !== "object") {
199
+ return JSON.stringify(v === undefined ? null : v);
200
+ }
201
+ if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
202
+ const keys = Object.keys(v).sort();
203
+ return (
204
+ "{" +
205
+ keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") +
206
+ "}"
207
+ );
208
+ }
209
+
210
+ const SERVER_DATA_METHODS = [
211
+ "get",
212
+ "list",
213
+ "lookup",
214
+ "query",
215
+ "queryGraph",
216
+ "paginate",
217
+ "search",
218
+ ];
219
+
220
+ // A React-recognized fulfilled thenable: use() reads .value synchronously
221
+ // (status === "fulfilled") instead of suspending. Critical for hydration —
222
+ // the server streamed the post-Suspense content, so the client must render
223
+ // that content on the FIRST pass without re-suspending (no fallback flash,
224
+ // no mismatch).
225
+ function fulfilledThenable(value) {
226
+ return {
227
+ status: "fulfilled",
228
+ value,
229
+ then(onFulfilled) {
230
+ return onFulfilled ? onFulfilled(value) : value;
231
+ },
232
+ };
233
+ }
234
+
235
+ // Client stand-in for the server's serverData handle. Each method returns a
236
+ // pre-fulfilled thenable (cached per key) sourced from the SSR'd ssrData map,
237
+ // keyed identically to the server. Misses yield undefined — the page
238
+ // rendered with whatever the server fetched, so a hit is expected.
239
+ function makeClientServerData(ssrData) {
240
+ const cache = ssrData || {};
241
+ const pc = new Map();
242
+ const wrap = (prefix) => {
243
+ const out = {};
244
+ for (const m of SERVER_DATA_METHODS) {
245
+ out[m] = (...args) => {
246
+ const key = prefix + m + ":" + stableStringify(args);
247
+ if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));
248
+ return pc.get(key);
249
+ };
250
+ }
251
+ return out;
252
+ };
253
+ const sd = wrap("");
254
+ sd.unsafe = wrap("u:");
255
+ return sd;
256
+ }
257
+
258
+ // Server-only response controller has no meaning on the client (the status/
259
+ // redirect/cookies already shipped). Give pages a no-op so a body that
260
+ // touches props.response during hydration doesn't crash.
261
+ function makeNoopResponse() {
262
+ const noop = () => {};
263
+ return {
264
+ setStatus: noop,
265
+ setHeader: noop,
266
+ setCookie: noop,
267
+ redirect: noop,
268
+ notFound: noop,
269
+ };
270
+ }
271
+
272
+ // Rehydrate the live, server-only props (serverData + response) that were
273
+ // stripped before serialization, so the client tree matches the server's.
274
+ function withClientProps(data) {
275
+ const props = { ...(data.props || {}) };
276
+ props.serverData = makeClientServerData(data.ssrData);
277
+ props.response = makeNoopResponse();
278
+ return props;
279
+ }
280
+
195
281
  function readPylonData() {
196
282
  const dataEl = document.getElementById("__PYLON_DATA__");
197
283
  if (!dataEl) return null;
@@ -286,7 +372,7 @@ export function hydrate(component, Page, Layouts) {
286
372
  );
287
373
  return;
288
374
  }
289
- const tree = buildTree(Page, Layouts, data.props);
375
+ const tree = buildTree(Page, Layouts, withClientProps(data));
290
376
  activeRoot = hydrateRoot(document, tree);
291
377
  installNavHandlers();
292
378
  return;
@@ -340,7 +426,7 @@ async function navigate(href, opts) {
340
426
  return;
341
427
  }
342
428
  document.title = doc.title || document.title;
343
- const tree = buildTree(route.Page, route.Layouts, data.props);
429
+ const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
344
430
  activeRoot.render(tree);
345
431
  if (push) {
346
432
  history.pushState({ component: data.component }, "", url.pathname + url.search);
@@ -55,6 +55,14 @@ export interface RenderRouteMessage {
55
55
  tenant_id: string | null;
56
56
  roles: string[];
57
57
  };
58
+ /**
59
+ * Initial HTTP status the response controller starts at (default 200).
60
+ * The host sets this to 404 when dispatching a `not-found.tsx` render
61
+ * for an unmatched URL, so the boundary streams at 404 without the
62
+ * component having to call `response.setStatus`. A page can still
63
+ * override it via `response.setStatus`.
64
+ */
65
+ initial_status?: number;
58
66
  }
59
67
 
60
68
  type Send = (msg: Record<string, unknown>) => void;
@@ -167,7 +175,12 @@ export interface SsrResponse {
167
175
  setCookie(name: string, value: string, opts?: SsrCookieOptions): void;
168
176
  /** Throw to send a 3xx (default 307) + Location, no body. Shell-render only. */
169
177
  redirect(url: string, status?: number): never;
170
- /** Throw to send a 404. Currently a fixed framework body (not-found.tsx not yet honored). Shell-render only. */
178
+ /**
179
+ * Throw to send a 404. Renders the nearest `not-found.tsx` (walking up
180
+ * from the page's directory, wrapped in the route's layout chain), or a
181
+ * minimal framework body if none is defined. Shell-render only — a throw
182
+ * below a Suspense boundary is swallowed by React.
183
+ */
171
184
  notFound(): never;
172
185
  }
173
186
 
@@ -364,14 +377,278 @@ async function buildLayoutTree(
364
377
  return tree;
365
378
  }
366
379
 
380
+ /**
381
+ * Walk up from a page's directory to the nearest boundary file
382
+ * (not-found / error) — the same render-time, filesystem-resolved model
383
+ * the page + layouts already use, so no build-time manifest threading.
384
+ * Returns the project-relative path (no extension) or null.
385
+ */
386
+ function findBoundary(componentPath: string, fileName: string): string | null {
387
+ const fs = require("node:fs");
388
+ const path = require("node:path");
389
+ const cwd = process.cwd();
390
+ // Component paths use "/" — walk up directory by directory.
391
+ let dir = componentPath.replace(/\\/g, "/");
392
+ dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
393
+ while (dir && dir !== "." && dir !== "/") {
394
+ for (const ext of MODULE_EXTS) {
395
+ if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
396
+ return `${dir}/${fileName}`;
397
+ }
398
+ }
399
+ const slash = dir.lastIndexOf("/");
400
+ dir = slash >= 0 ? dir.slice(0, slash) : "";
401
+ }
402
+ return null;
403
+ }
404
+
405
+ /**
406
+ * Drain a `renderToReadableStream` reader, injecting `headBlob` immediately
407
+ * before the first `</head>` (or, if the document has none, the blob is
408
+ * never emitted — fragment renders have no head). `</head>` can straddle a
409
+ * chunk boundary, so a small carry buffer (len("</head>") − 1 bytes) is
410
+ * withheld at each chunk's tail until the next read confirms the match.
411
+ * Each emitted slice is handed to `sendChunk` as utf-8 text.
412
+ *
413
+ * Shared by the page render and the boundary render so head injection has
414
+ * exactly one implementation.
415
+ */
416
+ async function streamWithHeadInjection(
417
+ reader: ReadableStreamDefaultReader<Uint8Array>,
418
+ headBlob: string,
419
+ sendChunk: (text: string) => void,
420
+ ): Promise<void> {
421
+ let headInjected = headBlob.length === 0;
422
+ let carry = "";
423
+ const HEAD_CLOSE = "</head>";
424
+ for (;;) {
425
+ const { value, done } = await reader.read();
426
+ if (done) break;
427
+ if (!value || value.byteLength === 0) continue;
428
+ const text = Buffer.from(value).toString("utf8");
429
+ if (!headInjected) {
430
+ const combined = carry + text;
431
+ const idx = combined.indexOf(HEAD_CLOSE);
432
+ if (idx >= 0) {
433
+ sendChunk(combined.slice(0, idx));
434
+ sendChunk(headBlob);
435
+ sendChunk(HEAD_CLOSE);
436
+ const after = combined.slice(idx + HEAD_CLOSE.length);
437
+ if (after) sendChunk(after);
438
+ headInjected = true;
439
+ carry = "";
440
+ } else {
441
+ const keep = HEAD_CLOSE.length - 1;
442
+ if (combined.length > keep) {
443
+ sendChunk(combined.slice(0, combined.length - keep));
444
+ carry = combined.slice(combined.length - keep);
445
+ } else {
446
+ carry = combined;
447
+ }
448
+ }
449
+ } else {
450
+ sendChunk(text);
451
+ }
452
+ }
453
+ if (carry) sendChunk(carry);
454
+ }
455
+
456
+ /**
457
+ * Build the <head> blob for a boundary render: the union of every route's
458
+ * stylesheet links from the client build manifest. Boundary modules aren't
459
+ * bundled as their own client entry, but they render inside the same
460
+ * layout/shell as pages, so without the app's global CSS a 404/500 page
461
+ * would look broken. Returns "" if the manifest can't be loaded — the
462
+ * boundary still renders (unstyled); CSS must never block the error path.
463
+ */
464
+ async function collectBoundaryHeadBlob(): Promise<string> {
465
+ try {
466
+ const { getManifest } = await import("./ssr-client-bundler");
467
+ const manifest = await getManifest();
468
+ const prefix = manifest.public_prefix || "/_pylon/build/";
469
+ const seen = new Set<string>();
470
+ let blob = "";
471
+ for (const route of Object.values(manifest.routes || {}) as any[]) {
472
+ for (const css of (route.css || []) as string[]) {
473
+ if (seen.has(css)) continue;
474
+ seen.add(css);
475
+ blob += `<link rel="stylesheet" href="${prefix}${css}">`;
476
+ }
477
+ }
478
+ return blob;
479
+ } catch {
480
+ return "";
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Render a boundary (not-found/error) tree and stream it as the response
486
+ * body at `status`. Boundaries render server-side only (no hydration
487
+ * payload) — they're informational pages, consistent with the keystone's
488
+ * fixed 404 body that this replaces — but they DO get the app's global
489
+ * stylesheet injected so they match the rest of the site.
490
+ */
491
+ async function renderBoundaryToClient(
492
+ React: any,
493
+ renderToReadableStream: any,
494
+ tree: any,
495
+ send: Send,
496
+ callId: string,
497
+ status: number,
498
+ headers: Record<string, string>,
499
+ ): Promise<void> {
500
+ const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
501
+ onError(e: unknown) {
502
+ // eslint-disable-next-line no-console
503
+ console.error("[ssr] boundary render error:", e);
504
+ },
505
+ });
506
+ const headBlob = await collectBoundaryHeadBlob();
507
+ // renderToReadableStream resolved without throwing → safe to commit the
508
+ // head now, then drain the (already-rendered) shell, injecting CSS.
509
+ send({ type: "response_start", call_id: callId, status, headers });
510
+ const sendChunk = (text: string) => {
511
+ if (!text) return;
512
+ send({
513
+ type: "render_chunk",
514
+ call_id: callId,
515
+ data: Buffer.from(text, "utf8").toString("base64"),
516
+ });
517
+ };
518
+ await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
519
+ send({ type: "render_done", call_id: callId });
520
+ }
521
+
522
+ /**
523
+ * Resolve + render a boundary component wrapped in the route's layout
524
+ * chain. Returns true if it rendered (caller returns), false to fall back.
525
+ */
526
+ async function tryRenderBoundary(
527
+ opts: {
528
+ React: any;
529
+ renderToReadableStream: any;
530
+ cwd: string;
531
+ componentPath: string;
532
+ fileName: "not-found" | "error";
533
+ layouts: string[] | undefined;
534
+ props: any;
535
+ send: Send;
536
+ callId: string;
537
+ status: number;
538
+ headers: Record<string, string>;
539
+ },
540
+ ): Promise<boolean> {
541
+ const { React, renderToReadableStream, cwd, componentPath, fileName, layouts, props, send, callId, status, headers } =
542
+ opts;
543
+ if (!React || !renderToReadableStream || !props) return false;
544
+ const rel = findBoundary(componentPath, fileName);
545
+ if (!rel) return false;
546
+ try {
547
+ const mod = await importModule(cwd, rel);
548
+ const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
549
+ if (typeof Comp !== "function") return false;
550
+ let tree = React.createElement(Comp, props);
551
+ tree = await buildLayoutTree(cwd, tree, layouts, props, React);
552
+ await renderBoundaryToClient(React, renderToReadableStream, tree, send, callId, status, headers);
553
+ return true;
554
+ } catch (e) {
555
+ // Boundary render itself failed — no tertiary fallback; let the caller
556
+ // emit its default (fixed 404 body / type:"error" → 500).
557
+ // eslint-disable-next-line no-console
558
+ console.error(`[ssr] ${fileName}.tsx boundary failed to render:`, e);
559
+ return false;
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Deterministic stringify (keys sorted recursively) so a `serverData` call's
565
+ * cache key is identical on the server (here) and on the client (the
566
+ * hydration shim in ssr-client-bundler's client-runtime). MUST stay in sync
567
+ * with `stableStringify` in that template.
568
+ */
569
+ function stableStringify(v: any): string {
570
+ if (v === null || v === undefined || typeof v !== "object") {
571
+ return JSON.stringify(v ?? null);
572
+ }
573
+ if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
574
+ const keys = Object.keys(v).sort();
575
+ return (
576
+ "{" +
577
+ keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") +
578
+ "}"
579
+ );
580
+ }
581
+
582
+ // The read methods a page reaches through `serverData`. Mirrors the
583
+ // DbReader read surface (writes are blocked server-side). Kept in sync with
584
+ // the client-runtime shim's method list.
585
+ const SERVER_DATA_METHODS = [
586
+ "get",
587
+ "list",
588
+ "lookup",
589
+ "query",
590
+ "queryGraph",
591
+ "paginate",
592
+ "search",
593
+ ] as const;
594
+
595
+ /**
596
+ * Wrap a DbReader so each `serverData.x(...)` call returns a PROMISE CACHED
597
+ * by (method, args) — required for React 19 `use()`, which re-invokes the
598
+ * call on the post-suspense re-render and must get the same (now-resolved)
599
+ * promise instead of a fresh pending one (else it suspends forever). Each
600
+ * resolved value is also recorded into `valueCache` keyed identically, so it
601
+ * can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
602
+ * — keeping hydration free of mismatches.
603
+ */
604
+ function makeServerData(reader: any, valueCache: Record<string, any>): any {
605
+ const promiseCache = new Map<string, Promise<any>>();
606
+ const wrap = (r: any, prefix: string): any => {
607
+ const out: any = {};
608
+ for (const m of SERVER_DATA_METHODS) {
609
+ out[m] = (...args: any[]) => {
610
+ const key = prefix + m + ":" + stableStringify(args);
611
+ let p = promiseCache.get(key);
612
+ if (!p) {
613
+ p = Promise.resolve(r[m](...args)).then((value: any) => {
614
+ valueCache[key] = value;
615
+ return value;
616
+ });
617
+ promiseCache.set(key, p);
618
+ }
619
+ return p;
620
+ };
621
+ }
622
+ return out;
623
+ };
624
+ const sd = wrap(reader, "");
625
+ if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
626
+ return sd;
627
+ }
628
+
367
629
  export async function handleRenderRoute(
368
630
  msg: RenderRouteMessage,
369
631
  send: Send,
370
632
  ): Promise<void> {
371
633
  // Declared OUTSIDE the try so the catch can read page-set status/
372
634
  // cookies when turning a redirect()/notFound() throw into a response.
373
- const responseState: ResponseState = { status: 200, headers: {}, cookies: [] };
635
+ const responseState: ResponseState = {
636
+ status: msg.initial_status ?? 200,
637
+ headers: {},
638
+ cookies: [],
639
+ };
374
640
  const response = makeResponseController(responseState);
641
+ // Hoisted out of the try so the catch can render not-found.tsx /
642
+ // error.tsx boundaries (which need React + the renderer + cwd + props).
643
+ const cwd = process.cwd();
644
+ let React: any = null;
645
+ let renderToReadableStream: any = null;
646
+ let props: any = null;
647
+ // Accumulates the resolved results of every `serverData.*` read the page
648
+ // made during render, keyed identically to the client shim. Serialized
649
+ // into `__PYLON_DATA__.ssrData` so hydration replays the same values
650
+ // without a second round trip — and without a server/client mismatch.
651
+ const ssrValueCache: Record<string, any> = {};
375
652
  try {
376
653
  // react + react-dom are USER deps. ssr-runtime.ts lives in
377
654
  // packages/functions/src/, but the user's react install is under
@@ -379,7 +656,6 @@ export async function handleRenderRoute(
379
656
  // would resolve against pylon's own node_modules (which doesn't
380
657
  // declare react), so we route through a Bun-resolveSync against
381
658
  // the user's cwd.
382
- const cwd = process.cwd();
383
659
  const resolveFromUser = (spec: string): string =>
384
660
  (Bun as any).resolveSync
385
661
  ? (Bun as any).resolveSync(spec, cwd)
@@ -405,8 +681,8 @@ export async function handleRenderRoute(
405
681
  const reactImport = await import(
406
682
  /* @vite-ignore */ resolveFromUser("react")
407
683
  );
408
- const React = reactImport.default ?? reactImport;
409
- const renderToReadableStream =
684
+ React = reactImport.default ?? reactImport;
685
+ renderToReadableStream =
410
686
  reactDomServerImport.renderToReadableStream ??
411
687
  reactDomServerImport.default?.renderToReadableStream;
412
688
  if (typeof renderToReadableStream !== "function") {
@@ -431,7 +707,20 @@ export async function handleRenderRoute(
431
707
  );
432
708
  }
433
709
 
434
- const props = {
710
+ // `serverData` a read-only DB handle the page reaches during render
711
+ // via React 19 `use()` + <Suspense>. Reuses runtime.ts's RPC pipe
712
+ // (shared `send` + pendingRpcs) keyed by this render's call_id; the
713
+ // Rust render loop answers the frames against the same store + policy
714
+ // gate as a query function's ctx.db, and rejects any write. Promise-
715
+ // cached so `use()` doesn't re-suspend forever; resolved values land in
716
+ // `ssrValueCache` for hydration replay.
717
+ const { buildDbReader } = await import("./runtime");
718
+ const serverData = makeServerData(
719
+ buildDbReader(msg.call_id),
720
+ ssrValueCache,
721
+ );
722
+
723
+ props = {
435
724
  url: msg.url,
436
725
  params: msg.params,
437
726
  searchParams: msg.search_params,
@@ -441,6 +730,8 @@ export async function handleRenderRoute(
441
730
  // Response controller — a page/layout calls response.setStatus /
442
731
  // setHeader / setCookie / redirect / notFound to shape the reply.
443
732
  response,
733
+ // Read-only server data handle (see above).
734
+ serverData,
444
735
  };
445
736
 
446
737
  // SEO metadata: static `export const metadata` or dynamic
@@ -484,6 +775,19 @@ export async function handleRenderRoute(
484
775
  },
485
776
  );
486
777
 
778
+ // Wait for ALL Suspense boundaries to resolve before emitting the body,
779
+ // so the HTML is fully formed — no `<!--$?-->` pending markers, no
780
+ // hidden fallback segments, no `$RC` reveal scripts. This is what makes
781
+ // `serverData` + `use()` + <Suspense> hydrate cleanly: the client
782
+ // hydrates a RESOLVED boundary against the SSR'd content (resolved from
783
+ // `ssrData`), instead of fighting React's streaming-reveal scripts +
784
+ // whole-document hydration (which leaves the boundary stuck on its
785
+ // fallback). Pages with no async data have no boundaries, so `allReady`
786
+ // resolves immediately — zero cost for the common case.
787
+ if ((stream as any).allReady) {
788
+ await (stream as any).allReady;
789
+ }
790
+
487
791
  // Headers go out before the first chunk so the host can write the
488
792
  // response head.
489
793
  // The shell rendered without a redirect()/notFound() throw, so the
@@ -530,16 +834,25 @@ export async function handleRenderRoute(
530
834
  for (const chunk of preloadManifestRoute.imports) {
531
835
  headBlob += `<link rel="modulepreload" href="${preloadPublicPrefix}${chunk}">`;
532
836
  }
837
+ } else {
838
+ // No per-route client entry. This is the unmatched-URL not-found
839
+ // dispatch (the host renders `app/not-found` by name at 404) or any
840
+ // other component without a hydration bundle. It still renders inside
841
+ // the app shell, so inject the global stylesheet(s) — otherwise the
842
+ // 404 page is unstyled. Hydration stays disabled (handled below).
843
+ headBlob += await collectBoundaryHeadBlob();
533
844
  }
534
845
 
535
- // Stream-rewrite: watch for `</head>` and inject `headBlob`
536
- // before it. `</head>` may straddle chunk boundaries so we
537
- // keep a small carry buffer (7 bytes — len("</head>")) at the
538
- // tail of each chunk.
539
- const reader = stream.getReader();
540
- let headInjected = headBlob.length === 0;
541
- let carry = ""; // utf8 tail from previous chunk for boundary detection
542
- const HEAD_CLOSE = "</head>";
846
+ // The host can dispatch a boundary module (`app/not-found` / `app/error`)
847
+ // by name for an unmatched-URL 404. Boundaries render server-only — no
848
+ // hydration payload, and no "hydration disabled" warning (that warning is
849
+ // for a real page whose client bundle is missing).
850
+ const isBoundaryComponent = /(^|\/)(not-found|error)$/.test(msg.component);
851
+
852
+ // Stream-rewrite: watch for `</head>` and inject `headBlob` before it.
853
+ // `</head>` may straddle chunk boundaries; the shared helper keeps a
854
+ // small carry buffer to catch a split tag. base64 of each utf-8 slice
855
+ // happens in `sendChunk` (Buffer ships with Bun).
543
856
  const sendChunk = (text: string) => {
544
857
  if (!text) return;
545
858
  send({
@@ -548,50 +861,7 @@ export async function handleRenderRoute(
548
861
  data: Buffer.from(text, "utf8").toString("base64"),
549
862
  });
550
863
  };
551
- while (true) {
552
- const { value, done } = await reader.read();
553
- if (done) break;
554
- if (!value || value.byteLength === 0) continue;
555
- let text = Buffer.from(value).toString("utf8");
556
- if (!headInjected) {
557
- const combined = carry + text;
558
- const idx = combined.indexOf(HEAD_CLOSE);
559
- if (idx >= 0) {
560
- // Send everything up to the </head> position, then the
561
- // headBlob, then </head>, then the remainder.
562
- const before = combined.slice(0, idx);
563
- const after = combined.slice(idx + HEAD_CLOSE.length);
564
- // Drop the carry portion from `before` that we already
565
- // emitted as part of the previous chunk's send. But since
566
- // we DIDN'T emit `carry` previously (it was withheld), we
567
- // can send the full `before` here.
568
- sendChunk(before);
569
- sendChunk(headBlob);
570
- sendChunk(HEAD_CLOSE);
571
- if (after) sendChunk(after);
572
- headInjected = true;
573
- carry = "";
574
- } else {
575
- // No </head> yet — emit everything except the last
576
- // (HEAD_CLOSE.length - 1) bytes so a tag split across
577
- // chunk boundaries still gets caught next pass.
578
- const keep = HEAD_CLOSE.length - 1;
579
- if (combined.length > keep) {
580
- sendChunk(combined.slice(0, combined.length - keep));
581
- carry = combined.slice(combined.length - keep);
582
- } else {
583
- carry = combined;
584
- }
585
- }
586
- } else {
587
- // base64 in pure JS via Buffer (Bun ships it). For large
588
- // pages this is O(n) per chunk; fine for Phase 1.
589
- sendChunk(text);
590
- }
591
- }
592
- // Flush any residual carry (head close never seen — page
593
- // didn't have a </head>, which is fine for fragment renders).
594
- if (carry) sendChunk(carry);
864
+ await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
595
865
 
596
866
  // Hydration tail. After React's stream EOFs we append the
597
867
  // hydration markers so the browser can hydrate:
@@ -611,28 +881,36 @@ export async function handleRenderRoute(
611
881
  // and `getManifest` parses with mtime-keyed caching. Falls back
612
882
  // to a no-hydration warning if the manifest can't be loaded
613
883
  // (rare — usually means the bundler crashed).
614
- const hydrationPayload = {
615
- component: msg.component,
616
- layouts: msg.layouts ?? [],
617
- props,
618
- };
619
- const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
884
+ if (!isBoundaryComponent) {
885
+ // Strip live, non-serializable handles from the props that seed
886
+ // hydration: `serverData` (an RPC handle — the client rebuilds its
887
+ // own from `ssrData`) and `response` (a server-only controller — the
888
+ // client gets a no-op). Their resolved data rides along in `ssrData`.
889
+ const { serverData: _sd, response: _resp, ...serializableProps } = props;
890
+ const hydrationPayload = {
891
+ component: msg.component,
892
+ layouts: msg.layouts ?? [],
893
+ props: serializableProps,
894
+ ssrData: ssrValueCache,
895
+ };
896
+ const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
620
897
 
621
- let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
622
- if (preloadManifestRoute) {
623
- // Per-route entry script comes last — it needs the inline
624
- // `__PYLON_DATA__` above to have been parsed before it runs.
625
- // CSS + modulepreload links were already injected into `<head>`
626
- // above so they could start fetching as early as possible.
627
- tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
628
- } else {
629
- tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
898
+ let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
899
+ if (preloadManifestRoute) {
900
+ // Per-route entry script comes last — it needs the inline
901
+ // `__PYLON_DATA__` above to have been parsed before it runs.
902
+ // CSS + modulepreload links were already injected into `<head>`
903
+ // above so they could start fetching as early as possible.
904
+ tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
905
+ } else {
906
+ tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
907
+ }
908
+ send({
909
+ type: "render_chunk",
910
+ call_id: msg.call_id,
911
+ data: Buffer.from(tail, "utf8").toString("base64"),
912
+ });
630
913
  }
631
- send({
632
- type: "render_chunk",
633
- call_id: msg.call_id,
634
- data: Buffer.from(tail, "utf8").toString("base64"),
635
- });
636
914
 
637
915
  send({ type: "render_done", call_id: msg.call_id });
638
916
  } catch (err: any) {
@@ -650,7 +928,26 @@ export async function handleRenderRoute(
650
928
  send({ type: "render_done", call_id: msg.call_id });
651
929
  return;
652
930
  }
653
- // notFound() → 404 with a minimal body (until not-found.tsx wiring).
931
+ // notFound() → look for the nearest not-found.tsx walking up from the
932
+ // page's directory; render it (wrapped in the route's layouts) at 404.
933
+ // Falls back to a minimal framework body if none is defined.
934
+ if (
935
+ await tryRenderBoundary({
936
+ React,
937
+ renderToReadableStream,
938
+ cwd,
939
+ componentPath: msg.component,
940
+ fileName: "not-found",
941
+ layouts: msg.layouts,
942
+ props,
943
+ send,
944
+ callId: msg.call_id,
945
+ status: 404,
946
+ headers: finalizeHeaders(responseState),
947
+ })
948
+ ) {
949
+ return;
950
+ }
654
951
  const body404 =
655
952
  '<!DOCTYPE html><html><head><meta charset="utf-8"><title>404 — Not Found</title></head><body><h1>404</h1><p>This page could not be found.</p></body></html>';
656
953
  send({
@@ -667,7 +964,27 @@ export async function handleRenderRoute(
667
964
  send({ type: "render_done", call_id: msg.call_id });
668
965
  return;
669
966
  }
670
- // Real pre-first-chunk error → host returns 500.
967
+ // Real pre-first-chunk error → look for the nearest error.tsx walking up
968
+ // from the page's directory; render it (wrapped in the route's layouts)
969
+ // at 500 with the thrown error passed in props. Falls back to a host-level
970
+ // 500 (type:"error") if none is defined or the boundary itself throws.
971
+ if (
972
+ await tryRenderBoundary({
973
+ React,
974
+ renderToReadableStream,
975
+ cwd,
976
+ componentPath: msg.component,
977
+ fileName: "error",
978
+ layouts: msg.layouts,
979
+ props: props ? { ...props, error: err } : null,
980
+ send,
981
+ callId: msg.call_id,
982
+ status: 500,
983
+ headers: finalizeHeaders(responseState),
984
+ })
985
+ ) {
986
+ return;
987
+ }
671
988
  send({
672
989
  type: "error",
673
990
  call_id: msg.call_id,