@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 +1 -1
- package/src/index.ts +1 -1
- package/src/ssr-runtime.ts +445 -136
package/package.json
CHANGED
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,
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 = {
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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() →
|
|
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 →
|
|
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,
|