@pylonsync/functions 0.3.236 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/ssr-runtime.ts +292 -80
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.236",
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",
@@ -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,207 @@ 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
+
367
563
  export async function handleRenderRoute(
368
564
  msg: RenderRouteMessage,
369
565
  send: Send,
370
566
  ): Promise<void> {
371
567
  // Declared OUTSIDE the try so the catch can read page-set status/
372
568
  // cookies when turning a redirect()/notFound() throw into a response.
373
- const responseState: ResponseState = { status: 200, headers: {}, cookies: [] };
569
+ const responseState: ResponseState = {
570
+ status: msg.initial_status ?? 200,
571
+ headers: {},
572
+ cookies: [],
573
+ };
374
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;
375
581
  try {
376
582
  // react + react-dom are USER deps. ssr-runtime.ts lives in
377
583
  // packages/functions/src/, but the user's react install is under
@@ -379,7 +585,6 @@ export async function handleRenderRoute(
379
585
  // would resolve against pylon's own node_modules (which doesn't
380
586
  // declare react), so we route through a Bun-resolveSync against
381
587
  // the user's cwd.
382
- const cwd = process.cwd();
383
588
  const resolveFromUser = (spec: string): string =>
384
589
  (Bun as any).resolveSync
385
590
  ? (Bun as any).resolveSync(spec, cwd)
@@ -405,8 +610,8 @@ export async function handleRenderRoute(
405
610
  const reactImport = await import(
406
611
  /* @vite-ignore */ resolveFromUser("react")
407
612
  );
408
- const React = reactImport.default ?? reactImport;
409
- const renderToReadableStream =
613
+ React = reactImport.default ?? reactImport;
614
+ renderToReadableStream =
410
615
  reactDomServerImport.renderToReadableStream ??
411
616
  reactDomServerImport.default?.renderToReadableStream;
412
617
  if (typeof renderToReadableStream !== "function") {
@@ -431,7 +636,7 @@ export async function handleRenderRoute(
431
636
  );
432
637
  }
433
638
 
434
- const props = {
639
+ props = {
435
640
  url: msg.url,
436
641
  params: msg.params,
437
642
  searchParams: msg.search_params,
@@ -530,16 +735,25 @@ export async function handleRenderRoute(
530
735
  for (const chunk of preloadManifestRoute.imports) {
531
736
  headBlob += `<link rel="modulepreload" href="${preloadPublicPrefix}${chunk}">`;
532
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();
533
745
  }
534
746
 
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>";
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).
543
757
  const sendChunk = (text: string) => {
544
758
  if (!text) return;
545
759
  send({
@@ -548,50 +762,7 @@ export async function handleRenderRoute(
548
762
  data: Buffer.from(text, "utf8").toString("base64"),
549
763
  });
550
764
  };
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);
765
+ await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
595
766
 
596
767
  // Hydration tail. After React's stream EOFs we append the
597
768
  // hydration markers so the browser can hydrate:
@@ -611,28 +782,30 @@ export async function handleRenderRoute(
611
782
  // and `getManifest` parses with mtime-keyed caching. Falls back
612
783
  // to a no-hydration warning if the manifest can't be loaded
613
784
  // (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");
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");
620
792
 
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>`;
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
+ });
630
808
  }
631
- send({
632
- type: "render_chunk",
633
- call_id: msg.call_id,
634
- data: Buffer.from(tail, "utf8").toString("base64"),
635
- });
636
809
 
637
810
  send({ type: "render_done", call_id: msg.call_id });
638
811
  } catch (err: any) {
@@ -650,7 +823,26 @@ export async function handleRenderRoute(
650
823
  send({ type: "render_done", call_id: msg.call_id });
651
824
  return;
652
825
  }
653
- // 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
+ }
654
846
  const body404 =
655
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>';
656
848
  send({
@@ -667,7 +859,27 @@ export async function handleRenderRoute(
667
859
  send({ type: "render_done", call_id: msg.call_id });
668
860
  return;
669
861
  }
670
- // 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
+ }
671
883
  send({
672
884
  type: "error",
673
885
  call_id: msg.call_id,