@pylonsync/functions 0.3.235 → 0.3.237

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.235",
3
+ "version": "0.3.237",
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/index.ts CHANGED
@@ -25,7 +25,7 @@ export { slugifyName, availableSlug } from "./slugify";
25
25
  // SSR page response controller — pages/layouts receive `response` in
26
26
  // props (response.setStatus / redirect / notFound / setHeader / setCookie).
27
27
  // Type-only; the runtime instance is injected per-render by the SSR adapter.
28
- export type { SsrResponse, SsrCookieOptions } from "./ssr-runtime";
28
+ export type { SsrResponse, SsrCookieOptions, SsrMetadata } from "./ssr-runtime";
29
29
  export type {
30
30
  QueryCtx,
31
31
  MutationCtx,
@@ -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
 
@@ -240,14 +253,331 @@ function finalizeHeaders(
240
253
  * has flushed) are uncatchable here — React's `onError` would have
241
254
  * to feed into a separate signal, deferred to Phase 1.5.
242
255
  */
256
+ /**
257
+ * Page SEO metadata. A page exports `export const metadata = {...}`
258
+ * (static) or `export async function generateMetadata(props)` (dynamic,
259
+ * e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
260
+ *
261
+ * React 19 hoists the resulting <title>/<meta>/<link> into <head>. A page
262
+ * `title` overrides a layout's static `<title>` (both render; the browser
263
+ * uses the last, which is the page's). React does NOT dedupe arbitrary
264
+ * `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
265
+ * not both, to avoid duplicate tags.
266
+ */
267
+ export interface SsrMetadata {
268
+ title?: string;
269
+ description?: string;
270
+ keywords?: string | string[];
271
+ canonical?: string;
272
+ robots?: string;
273
+ openGraph?: {
274
+ title?: string;
275
+ description?: string;
276
+ image?: string;
277
+ url?: string;
278
+ type?: string;
279
+ };
280
+ twitter?: {
281
+ card?: string;
282
+ title?: string;
283
+ description?: string;
284
+ image?: string;
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Build a React fragment of <title>/<meta>/<link> from a page's metadata.
290
+ * React 19 auto-hoists these into <head> wherever they render, and the
291
+ * host's </head> splice preserves them. React escapes all text/attrs, so
292
+ * there's no manual XSS handling. Returns null when there's nothing to emit.
293
+ */
294
+ function renderMetadata(React: any, m: SsrMetadata | undefined): any {
295
+ if (!m) return null;
296
+ const el = React.createElement;
297
+ const kids: any[] = [];
298
+ if (m.title != null) kids.push(el("title", { key: "t" }, m.title));
299
+ if (m.description != null) {
300
+ kids.push(el("meta", { key: "d", name: "description", content: m.description }));
301
+ }
302
+ const kw = Array.isArray(m.keywords) ? m.keywords.join(", ") : m.keywords;
303
+ if (kw) kids.push(el("meta", { key: "kw", name: "keywords", content: kw }));
304
+ if (m.robots) kids.push(el("meta", { key: "r", name: "robots", content: m.robots }));
305
+ if (m.canonical) {
306
+ kids.push(el("link", { key: "c", rel: "canonical", href: m.canonical }));
307
+ }
308
+ const og = m.openGraph;
309
+ if (og) {
310
+ if (og.title != null) kids.push(el("meta", { key: "ogt", property: "og:title", content: og.title }));
311
+ if (og.description != null) kids.push(el("meta", { key: "ogd", property: "og:description", content: og.description }));
312
+ if (og.image) kids.push(el("meta", { key: "ogi", property: "og:image", content: og.image }));
313
+ if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
314
+ if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
315
+ }
316
+ const tw = m.twitter;
317
+ if (tw) {
318
+ if (tw.card) kids.push(el("meta", { key: "twc", name: "twitter:card", content: tw.card }));
319
+ if (tw.title != null) kids.push(el("meta", { key: "twt", name: "twitter:title", content: tw.title }));
320
+ if (tw.description != null) kids.push(el("meta", { key: "twd", name: "twitter:description", content: tw.description }));
321
+ if (tw.image) kids.push(el("meta", { key: "twi", name: "twitter:image", content: tw.image }));
322
+ }
323
+ return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
324
+ }
325
+
326
+ const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
327
+
328
+ /** Import a project-relative module, trying each common extension. */
329
+ async function importModule(cwd: string, relPath: string): Promise<any> {
330
+ const base = `${cwd}/${relPath}`;
331
+ let lastErr: unknown = null;
332
+ for (const ext of MODULE_EXTS) {
333
+ try {
334
+ return await import(`${base}${ext}`);
335
+ } catch (e) {
336
+ lastErr = e;
337
+ }
338
+ }
339
+ throw lastErr ?? new Error(`could not import module "${relPath}"`);
340
+ }
341
+
342
+ /**
343
+ * Wrap a leaf element in its layout chain (leaf → root). Resolves ALL
344
+ * layouts first so a missing one fails before any chunk is emitted. Reused
345
+ * by the page render and by the not-found / error boundary render.
346
+ */
347
+ async function buildLayoutTree(
348
+ cwd: string,
349
+ leaf: any,
350
+ layouts: string[] | undefined,
351
+ props: any,
352
+ React: any,
353
+ ): Promise<any> {
354
+ if (!layouts || layouts.length === 0) return leaf;
355
+ const layoutComps: any[] = [];
356
+ for (const layoutPath of layouts) {
357
+ let lMod: any;
358
+ try {
359
+ lMod = await importModule(cwd, layoutPath);
360
+ } catch {
361
+ throw new Error(
362
+ `could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
363
+ );
364
+ }
365
+ const LayoutComp = lMod.default ?? lMod.Layout ?? lMod.layout;
366
+ if (typeof LayoutComp !== "function") {
367
+ throw new Error(
368
+ `layout "${layoutPath}" has no default export (or named export "Layout")`,
369
+ );
370
+ }
371
+ layoutComps.push(LayoutComp);
372
+ }
373
+ let tree = leaf;
374
+ for (let i = layoutComps.length - 1; i >= 0; i--) {
375
+ tree = React.createElement(layoutComps[i], props, tree);
376
+ }
377
+ return tree;
378
+ }
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
+
243
563
  export async function handleRenderRoute(
244
564
  msg: RenderRouteMessage,
245
565
  send: Send,
246
566
  ): Promise<void> {
247
567
  // Declared OUTSIDE the try so the catch can read page-set status/
248
568
  // cookies when turning a redirect()/notFound() throw into a response.
249
- const responseState: ResponseState = { status: 200, headers: {}, cookies: [] };
569
+ const responseState: ResponseState = {
570
+ status: msg.initial_status ?? 200,
571
+ headers: {},
572
+ cookies: [],
573
+ };
250
574
  const response = makeResponseController(responseState);
575
+ // Hoisted out of the try so the catch can render not-found.tsx /
576
+ // error.tsx boundaries (which need React + the renderer + cwd + props).
577
+ const cwd = process.cwd();
578
+ let React: any = null;
579
+ let renderToReadableStream: any = null;
580
+ let props: any = null;
251
581
  try {
252
582
  // react + react-dom are USER deps. ssr-runtime.ts lives in
253
583
  // packages/functions/src/, but the user's react install is under
@@ -255,7 +585,6 @@ export async function handleRenderRoute(
255
585
  // would resolve against pylon's own node_modules (which doesn't
256
586
  // declare react), so we route through a Bun-resolveSync against
257
587
  // the user's cwd.
258
- const cwd = process.cwd();
259
588
  const resolveFromUser = (spec: string): string =>
260
589
  (Bun as any).resolveSync
261
590
  ? (Bun as any).resolveSync(spec, cwd)
@@ -281,8 +610,8 @@ export async function handleRenderRoute(
281
610
  const reactImport = await import(
282
611
  /* @vite-ignore */ resolveFromUser("react")
283
612
  );
284
- const React = reactImport.default ?? reactImport;
285
- const renderToReadableStream =
613
+ React = reactImport.default ?? reactImport;
614
+ renderToReadableStream =
286
615
  reactDomServerImport.renderToReadableStream ??
287
616
  reactDomServerImport.default?.renderToReadableStream;
288
617
  if (typeof renderToReadableStream !== "function") {
@@ -291,23 +620,14 @@ export async function handleRenderRoute(
291
620
  );
292
621
  }
293
622
 
294
- // Resolve the page module. The component string is project-
295
- // relative without extension; try .tsx → .ts → .jsx → .js so
296
- // any of the common page-file shapes work. cwd was captured
297
- // above for the react resolver.
298
- const baseName = `${cwd}/${msg.component}`;
299
- let mod: any = null;
300
- let lastErr: unknown = null;
301
- for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
302
- try {
303
- mod = await import(`${baseName}${ext}`);
304
- break;
305
- } catch (e) {
306
- lastErr = e;
307
- }
308
- }
309
- if (!mod) {
310
- throw lastErr ?? new Error(`could not import component "${msg.component}"`);
623
+ // Resolve the page module (project-relative, extension-agnostic).
624
+ let mod: any;
625
+ try {
626
+ mod = await importModule(cwd, msg.component);
627
+ } catch (e) {
628
+ throw e instanceof Error
629
+ ? e
630
+ : new Error(`could not import component "${msg.component}"`);
311
631
  }
312
632
  const Component = mod.default ?? mod.Page ?? mod.page;
313
633
  if (typeof Component !== "function") {
@@ -316,7 +636,7 @@ export async function handleRenderRoute(
316
636
  );
317
637
  }
318
638
 
319
- const props = {
639
+ props = {
320
640
  url: msg.url,
321
641
  params: msg.params,
322
642
  searchParams: msg.search_params,
@@ -328,50 +648,32 @@ export async function handleRenderRoute(
328
648
  response,
329
649
  };
330
650
 
651
+ // SEO metadata: static `export const metadata` or dynamic
652
+ // `export async function generateMetadata(props)`. Awaited before the
653
+ // first byte, so keep it to cheap derivations (params → title); heavy
654
+ // data belongs in the page body behind <Suspense>.
655
+ let metadata: SsrMetadata | undefined = mod.metadata;
656
+ if (typeof mod.generateMetadata === "function") {
657
+ metadata = await mod.generateMetadata(props);
658
+ }
659
+ const metaFragment = renderMetadata(React, metadata);
660
+
331
661
  // Resolve the layout chain. Each layout module exports a default
332
662
  // function that accepts the same props + `children`. Walk leaf →
333
663
  // root: start with the page component as `tree`, then for each
334
664
  // layout (innermost first) wrap it as the new tree. Result is
335
665
  // the outermost layout containing all nested layouts down to
336
- // the page.
337
- let tree: any = React.createElement(Component, props);
338
- const layouts = msg.layouts ?? [];
339
- if (layouts.length > 0) {
340
- // Resolve all layouts first so we fail fast on a missing one
341
- // BEFORE we start emitting headers / chunks.
342
- const layoutMods: any[] = [];
343
- for (const layoutPath of layouts) {
344
- const lBase = `${cwd}/${layoutPath}`;
345
- let lMod: any = null;
346
- for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
347
- try {
348
- lMod = await import(`${lBase}${ext}`);
349
- break;
350
- } catch {
351
- // try next extension
352
- }
353
- }
354
- if (!lMod) {
355
- throw new Error(
356
- `could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
357
- );
358
- }
359
- const LayoutComp =
360
- lMod.default ?? lMod.Layout ?? lMod.layout;
361
- if (typeof LayoutComp !== "function") {
362
- throw new Error(
363
- `layout "${layoutPath}" has no default export (or named export "Layout")`,
364
- );
365
- }
366
- layoutMods.push(LayoutComp);
367
- }
368
- // Walk LEAF → ROOT (reverse iteration on the layouts array).
369
- // The innermost layout wraps the page first; each outer layout
370
- // wraps the result.
371
- for (let i = layoutMods.length - 1; i >= 0; i--) {
372
- tree = React.createElement(layoutMods[i], props, tree);
373
- }
374
- }
666
+ // the page. The metadata fragment is the FIRST child so React hoists
667
+ // its <title>/<meta> into the <head> a layout renders.
668
+ let tree: any = metaFragment
669
+ ? React.createElement(
670
+ React.Fragment,
671
+ null,
672
+ metaFragment,
673
+ React.createElement(Component, props),
674
+ )
675
+ : React.createElement(Component, props);
676
+ tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
375
677
  const element = tree;
376
678
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
377
679
  element,
@@ -433,16 +735,25 @@ export async function handleRenderRoute(
433
735
  for (const chunk of preloadManifestRoute.imports) {
434
736
  headBlob += `<link rel="modulepreload" href="${preloadPublicPrefix}${chunk}">`;
435
737
  }
738
+ } else {
739
+ // No per-route client entry. This is the unmatched-URL not-found
740
+ // dispatch (the host renders `app/not-found` by name at 404) or any
741
+ // other component without a hydration bundle. It still renders inside
742
+ // the app shell, so inject the global stylesheet(s) — otherwise the
743
+ // 404 page is unstyled. Hydration stays disabled (handled below).
744
+ headBlob += await collectBoundaryHeadBlob();
436
745
  }
437
746
 
438
- // Stream-rewrite: watch for `</head>` and inject `headBlob`
439
- // before it. `</head>` may straddle chunk boundaries so we
440
- // keep a small carry buffer (7 bytes — len("</head>")) at the
441
- // tail of each chunk.
442
- const reader = stream.getReader();
443
- let headInjected = headBlob.length === 0;
444
- let carry = ""; // utf8 tail from previous chunk for boundary detection
445
- const HEAD_CLOSE = "</head>";
747
+ // The host can dispatch a boundary module (`app/not-found` / `app/error`)
748
+ // by name for an unmatched-URL 404. Boundaries render server-only — no
749
+ // hydration payload, and no "hydration disabled" warning (that warning is
750
+ // for a real page whose client bundle is missing).
751
+ const isBoundaryComponent = /(^|\/)(not-found|error)$/.test(msg.component);
752
+
753
+ // Stream-rewrite: watch for `</head>` and inject `headBlob` before it.
754
+ // `</head>` may straddle chunk boundaries; the shared helper keeps a
755
+ // small carry buffer to catch a split tag. base64 of each utf-8 slice
756
+ // happens in `sendChunk` (Buffer ships with Bun).
446
757
  const sendChunk = (text: string) => {
447
758
  if (!text) return;
448
759
  send({
@@ -451,50 +762,7 @@ export async function handleRenderRoute(
451
762
  data: Buffer.from(text, "utf8").toString("base64"),
452
763
  });
453
764
  };
454
- while (true) {
455
- const { value, done } = await reader.read();
456
- if (done) break;
457
- if (!value || value.byteLength === 0) continue;
458
- let text = Buffer.from(value).toString("utf8");
459
- if (!headInjected) {
460
- const combined = carry + text;
461
- const idx = combined.indexOf(HEAD_CLOSE);
462
- if (idx >= 0) {
463
- // Send everything up to the </head> position, then the
464
- // headBlob, then </head>, then the remainder.
465
- const before = combined.slice(0, idx);
466
- const after = combined.slice(idx + HEAD_CLOSE.length);
467
- // Drop the carry portion from `before` that we already
468
- // emitted as part of the previous chunk's send. But since
469
- // we DIDN'T emit `carry` previously (it was withheld), we
470
- // can send the full `before` here.
471
- sendChunk(before);
472
- sendChunk(headBlob);
473
- sendChunk(HEAD_CLOSE);
474
- if (after) sendChunk(after);
475
- headInjected = true;
476
- carry = "";
477
- } else {
478
- // No </head> yet — emit everything except the last
479
- // (HEAD_CLOSE.length - 1) bytes so a tag split across
480
- // chunk boundaries still gets caught next pass.
481
- const keep = HEAD_CLOSE.length - 1;
482
- if (combined.length > keep) {
483
- sendChunk(combined.slice(0, combined.length - keep));
484
- carry = combined.slice(combined.length - keep);
485
- } else {
486
- carry = combined;
487
- }
488
- }
489
- } else {
490
- // base64 in pure JS via Buffer (Bun ships it). For large
491
- // pages this is O(n) per chunk; fine for Phase 1.
492
- sendChunk(text);
493
- }
494
- }
495
- // Flush any residual carry (head close never seen — page
496
- // didn't have a </head>, which is fine for fragment renders).
497
- if (carry) sendChunk(carry);
765
+ await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
498
766
 
499
767
  // Hydration tail. After React's stream EOFs we append the
500
768
  // hydration markers so the browser can hydrate:
@@ -514,28 +782,30 @@ export async function handleRenderRoute(
514
782
  // and `getManifest` parses with mtime-keyed caching. Falls back
515
783
  // to a no-hydration warning if the manifest can't be loaded
516
784
  // (rare — usually means the bundler crashed).
517
- const hydrationPayload = {
518
- component: msg.component,
519
- layouts: msg.layouts ?? [],
520
- props,
521
- };
522
- const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
785
+ if (!isBoundaryComponent) {
786
+ const hydrationPayload = {
787
+ component: msg.component,
788
+ layouts: msg.layouts ?? [],
789
+ props,
790
+ };
791
+ const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
523
792
 
524
- let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
525
- if (preloadManifestRoute) {
526
- // Per-route entry script comes last — it needs the inline
527
- // `__PYLON_DATA__` above to have been parsed before it runs.
528
- // CSS + modulepreload links were already injected into `<head>`
529
- // above so they could start fetching as early as possible.
530
- tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
531
- } else {
532
- tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
793
+ let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
794
+ if (preloadManifestRoute) {
795
+ // Per-route entry script comes last — it needs the inline
796
+ // `__PYLON_DATA__` above to have been parsed before it runs.
797
+ // CSS + modulepreload links were already injected into `<head>`
798
+ // above so they could start fetching as early as possible.
799
+ tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
800
+ } else {
801
+ tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
802
+ }
803
+ send({
804
+ type: "render_chunk",
805
+ call_id: msg.call_id,
806
+ data: Buffer.from(tail, "utf8").toString("base64"),
807
+ });
533
808
  }
534
- send({
535
- type: "render_chunk",
536
- call_id: msg.call_id,
537
- data: Buffer.from(tail, "utf8").toString("base64"),
538
- });
539
809
 
540
810
  send({ type: "render_done", call_id: msg.call_id });
541
811
  } catch (err: any) {
@@ -553,7 +823,26 @@ export async function handleRenderRoute(
553
823
  send({ type: "render_done", call_id: msg.call_id });
554
824
  return;
555
825
  }
556
- // notFound() → 404 with a minimal body (until not-found.tsx wiring).
826
+ // notFound() → look for the nearest not-found.tsx walking up from the
827
+ // page's directory; render it (wrapped in the route's layouts) at 404.
828
+ // Falls back to a minimal framework body if none is defined.
829
+ if (
830
+ await tryRenderBoundary({
831
+ React,
832
+ renderToReadableStream,
833
+ cwd,
834
+ componentPath: msg.component,
835
+ fileName: "not-found",
836
+ layouts: msg.layouts,
837
+ props,
838
+ send,
839
+ callId: msg.call_id,
840
+ status: 404,
841
+ headers: finalizeHeaders(responseState),
842
+ })
843
+ ) {
844
+ return;
845
+ }
557
846
  const body404 =
558
847
  '<!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>';
559
848
  send({
@@ -570,7 +859,27 @@ export async function handleRenderRoute(
570
859
  send({ type: "render_done", call_id: msg.call_id });
571
860
  return;
572
861
  }
573
- // Real pre-first-chunk error → host returns 500.
862
+ // Real pre-first-chunk error → look for the nearest error.tsx walking up
863
+ // from the page's directory; render it (wrapped in the route's layouts)
864
+ // at 500 with the thrown error passed in props. Falls back to a host-level
865
+ // 500 (type:"error") if none is defined or the boundary itself throws.
866
+ if (
867
+ await tryRenderBoundary({
868
+ React,
869
+ renderToReadableStream,
870
+ cwd,
871
+ componentPath: msg.component,
872
+ fileName: "error",
873
+ layouts: msg.layouts,
874
+ props: props ? { ...props, error: err } : null,
875
+ send,
876
+ callId: msg.call_id,
877
+ status: 500,
878
+ headers: finalizeHeaders(responseState),
879
+ })
880
+ ) {
881
+ return;
882
+ }
574
883
  send({
575
884
  type: "error",
576
885
  call_id: msg.call_id,