@pylonsync/functions 0.3.234 → 0.3.236
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 +4 -0
- package/src/ssr-runtime.ts +368 -59
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -22,6 +22,10 @@ export { query, mutation, action } from "./define";
|
|
|
22
22
|
export { v } from "./validators";
|
|
23
23
|
export { resetDb, installTestIsolation } from "./testing";
|
|
24
24
|
export { slugifyName, availableSlug } from "./slugify";
|
|
25
|
+
// SSR page response controller — pages/layouts receive `response` in
|
|
26
|
+
// props (response.setStatus / redirect / notFound / setHeader / setCookie).
|
|
27
|
+
// Type-only; the runtime instance is injected per-render by the SSR adapter.
|
|
28
|
+
export type { SsrResponse, SsrCookieOptions, SsrMetadata } from "./ssr-runtime";
|
|
25
29
|
export type {
|
|
26
30
|
QueryCtx,
|
|
27
31
|
MutationCtx,
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -59,6 +59,177 @@ export interface RenderRouteMessage {
|
|
|
59
59
|
|
|
60
60
|
type Send = (msg: Record<string, unknown>) => void;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Control-flow signal a page or layout throws to short-circuit the
|
|
64
|
+
* render: `response.redirect(url)` / `response.notFound()`. The adapter
|
|
65
|
+
* catches it and turns it into a 3xx + Location or a 404 instead of a
|
|
66
|
+
* normal 200 body. Extends Error so React's stream rejects cleanly when
|
|
67
|
+
* it's thrown during the shell render. (Throw it OUTSIDE an error
|
|
68
|
+
* boundary — an enclosing boundary would swallow the signal.)
|
|
69
|
+
*/
|
|
70
|
+
class PylonRouteControl extends Error {
|
|
71
|
+
kind: "redirect" | "notFound";
|
|
72
|
+
url?: string;
|
|
73
|
+
redirectStatus?: number;
|
|
74
|
+
constructor(kind: "redirect" | "notFound") {
|
|
75
|
+
super(`__pylon_route_${kind}`);
|
|
76
|
+
this.kind = kind;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SsrCookieOptions {
|
|
81
|
+
path?: string;
|
|
82
|
+
domain?: string;
|
|
83
|
+
maxAge?: number;
|
|
84
|
+
expires?: Date | string;
|
|
85
|
+
/** Defaults to true (secure default). Pass false for a client-readable cookie. */
|
|
86
|
+
httpOnly?: boolean;
|
|
87
|
+
secure?: boolean;
|
|
88
|
+
/** Defaults to "lax". */
|
|
89
|
+
sameSite?: "strict" | "lax" | "none";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reject CR / LF / NUL — the characters that turn a header or cookie
|
|
94
|
+
* value into HTTP response splitting. The host re-checks at the wire
|
|
95
|
+
* edge, but failing here gives the developer a clear error instead of a
|
|
96
|
+
* silently-dropped header.
|
|
97
|
+
*/
|
|
98
|
+
function assertNoControlChars(s: string, label: string): void {
|
|
99
|
+
if (/[\r\n\0]/.test(s)) {
|
|
100
|
+
throw new Error(`pylon ssr: ${label} must not contain CR, LF, or NUL`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// RFC 6265 / RFC 7230 token — used for cookie + header NAMES.
|
|
105
|
+
const TOKEN_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
|
|
106
|
+
|
|
107
|
+
function serializeCookie(
|
|
108
|
+
name: string,
|
|
109
|
+
value: string,
|
|
110
|
+
opts: SsrCookieOptions = {},
|
|
111
|
+
): string {
|
|
112
|
+
// Cookie name must be a token — reject anything that could smuggle an
|
|
113
|
+
// attribute or a newline through the name (the value is encoded below).
|
|
114
|
+
if (!TOKEN_RE.test(name)) {
|
|
115
|
+
throw new Error(`pylon ssr: invalid cookie name ${JSON.stringify(name)}`);
|
|
116
|
+
}
|
|
117
|
+
let c = `${name}=${encodeURIComponent(value)}`;
|
|
118
|
+
if (opts.maxAge != null) c += `; Max-Age=${Math.floor(opts.maxAge)}`;
|
|
119
|
+
if (opts.expires) {
|
|
120
|
+
const exp =
|
|
121
|
+
typeof opts.expires === "string"
|
|
122
|
+
? opts.expires
|
|
123
|
+
: opts.expires.toUTCString();
|
|
124
|
+
assertNoControlChars(exp, "cookie expires");
|
|
125
|
+
c += `; Expires=${exp}`;
|
|
126
|
+
}
|
|
127
|
+
const path = opts.path ?? "/";
|
|
128
|
+
assertNoControlChars(path, "cookie path");
|
|
129
|
+
c += `; Path=${path}`;
|
|
130
|
+
if (opts.domain) {
|
|
131
|
+
assertNoControlChars(opts.domain, "cookie domain");
|
|
132
|
+
c += `; Domain=${opts.domain}`;
|
|
133
|
+
}
|
|
134
|
+
if (opts.httpOnly !== false) c += `; HttpOnly`;
|
|
135
|
+
if (opts.secure) c += `; Secure`;
|
|
136
|
+
const ss = opts.sameSite ?? "lax";
|
|
137
|
+
c += `; SameSite=${ss[0].toUpperCase()}${ss.slice(1)}`;
|
|
138
|
+
return c;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface ResponseState {
|
|
142
|
+
status: number;
|
|
143
|
+
headers: Record<string, string>;
|
|
144
|
+
cookies: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The per-render `response` controller handed to every page + layout in
|
|
149
|
+
* props. Pylon already has a backend for data/mutations, so SSR's job is
|
|
150
|
+
* just the response envelope: status, redirects, 404, and the occasional
|
|
151
|
+
* Set-Cookie.
|
|
152
|
+
*
|
|
153
|
+
* IMPORTANT — call these during the SYNCHRONOUS shell render (the
|
|
154
|
+
* component body, before any `await` / Suspense boundary). The HTTP head
|
|
155
|
+
* is committed when the shell is ready; status/headers/cookies set from a
|
|
156
|
+
* suspended subtree that streams in later are lost, and a redirect()/
|
|
157
|
+
* notFound() thrown below a Suspense boundary is caught by React's error
|
|
158
|
+
* handling rather than turned into a 3xx/404 (same constraint as Next's
|
|
159
|
+
* streaming SSR). Per render, not shared across requests.
|
|
160
|
+
*/
|
|
161
|
+
export interface SsrResponse {
|
|
162
|
+
/** Set the HTTP status (100–599). Default 200. */
|
|
163
|
+
setStatus(code: number): void;
|
|
164
|
+
/** Set a response header (name must be a token; value CR/LF/NUL-free). */
|
|
165
|
+
setHeader(name: string, value: string): void;
|
|
166
|
+
/** Append a Set-Cookie. Defaults: HttpOnly + SameSite=Lax. */
|
|
167
|
+
setCookie(name: string, value: string, opts?: SsrCookieOptions): void;
|
|
168
|
+
/** Throw to send a 3xx (default 307) + Location, no body. Shell-render only. */
|
|
169
|
+
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. */
|
|
171
|
+
notFound(): never;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makeResponseController(state: ResponseState): SsrResponse {
|
|
175
|
+
return {
|
|
176
|
+
setStatus(code) {
|
|
177
|
+
if (!Number.isInteger(code) || code < 100 || code > 599) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`pylon ssr: setStatus() expects an HTTP status 100–599, got ${code}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
state.status = code;
|
|
183
|
+
},
|
|
184
|
+
setHeader(name, value) {
|
|
185
|
+
if (!TOKEN_RE.test(name)) {
|
|
186
|
+
throw new Error(`pylon ssr: invalid header name ${JSON.stringify(name)}`);
|
|
187
|
+
}
|
|
188
|
+
assertNoControlChars(value, "header value");
|
|
189
|
+
state.headers[name.toLowerCase()] = value;
|
|
190
|
+
},
|
|
191
|
+
setCookie(name, value, opts) {
|
|
192
|
+
state.cookies.push(serializeCookie(name, value, opts));
|
|
193
|
+
},
|
|
194
|
+
redirect(url, status = 307): never {
|
|
195
|
+
assertNoControlChars(url, "redirect url");
|
|
196
|
+
if (!Number.isInteger(status) || status < 300 || status > 399) {
|
|
197
|
+
throw new Error(`pylon ssr: redirect() status must be 3xx, got ${status}`);
|
|
198
|
+
}
|
|
199
|
+
const e = new PylonRouteControl("redirect");
|
|
200
|
+
e.url = url;
|
|
201
|
+
e.redirectStatus = status;
|
|
202
|
+
throw e;
|
|
203
|
+
},
|
|
204
|
+
notFound(): never {
|
|
205
|
+
throw new PylonRouteControl("notFound");
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Merge page-set headers + cookies into the response_start header map.
|
|
212
|
+
* Cookies are newline-joined under `set-cookie`; the host splits them
|
|
213
|
+
* into one `Set-Cookie` header each (newline is forbidden inside a
|
|
214
|
+
* cookie, so it can't be turned into header injection).
|
|
215
|
+
*/
|
|
216
|
+
function finalizeHeaders(
|
|
217
|
+
state: ResponseState,
|
|
218
|
+
extra?: Record<string, string>,
|
|
219
|
+
): Record<string, string> {
|
|
220
|
+
const h: Record<string, string> = { ...state.headers, ...(extra ?? {}) };
|
|
221
|
+
if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
|
|
222
|
+
if (state.cookies.length > 0) {
|
|
223
|
+
// Preserve a set-cookie value set via setHeader() (rare) and join it
|
|
224
|
+
// with setCookie() entries rather than clobbering it. The host splits
|
|
225
|
+
// the newline-joined value into one Set-Cookie header each.
|
|
226
|
+
const existing = h["set-cookie"];
|
|
227
|
+
const all = existing ? [existing, ...state.cookies] : state.cookies;
|
|
228
|
+
h["set-cookie"] = all.join("\n");
|
|
229
|
+
}
|
|
230
|
+
return h;
|
|
231
|
+
}
|
|
232
|
+
|
|
62
233
|
/**
|
|
63
234
|
* Phase 1 SSR handler. Resolves the component, renders it via
|
|
64
235
|
* react-dom/server.renderToReadableStream, pumps chunks back to the
|
|
@@ -69,10 +240,138 @@ type Send = (msg: Record<string, unknown>) => void;
|
|
|
69
240
|
* has flushed) are uncatchable here — React's `onError` would have
|
|
70
241
|
* to feed into a separate signal, deferred to Phase 1.5.
|
|
71
242
|
*/
|
|
243
|
+
/**
|
|
244
|
+
* Page SEO metadata. A page exports `export const metadata = {...}`
|
|
245
|
+
* (static) or `export async function generateMetadata(props)` (dynamic,
|
|
246
|
+
* e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
|
|
247
|
+
*
|
|
248
|
+
* React 19 hoists the resulting <title>/<meta>/<link> into <head>. A page
|
|
249
|
+
* `title` overrides a layout's static `<title>` (both render; the browser
|
|
250
|
+
* uses the last, which is the page's). React does NOT dedupe arbitrary
|
|
251
|
+
* `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
|
|
252
|
+
* not both, to avoid duplicate tags.
|
|
253
|
+
*/
|
|
254
|
+
export interface SsrMetadata {
|
|
255
|
+
title?: string;
|
|
256
|
+
description?: string;
|
|
257
|
+
keywords?: string | string[];
|
|
258
|
+
canonical?: string;
|
|
259
|
+
robots?: string;
|
|
260
|
+
openGraph?: {
|
|
261
|
+
title?: string;
|
|
262
|
+
description?: string;
|
|
263
|
+
image?: string;
|
|
264
|
+
url?: string;
|
|
265
|
+
type?: string;
|
|
266
|
+
};
|
|
267
|
+
twitter?: {
|
|
268
|
+
card?: string;
|
|
269
|
+
title?: string;
|
|
270
|
+
description?: string;
|
|
271
|
+
image?: string;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build a React fragment of <title>/<meta>/<link> from a page's metadata.
|
|
277
|
+
* React 19 auto-hoists these into <head> wherever they render, and the
|
|
278
|
+
* host's </head> splice preserves them. React escapes all text/attrs, so
|
|
279
|
+
* there's no manual XSS handling. Returns null when there's nothing to emit.
|
|
280
|
+
*/
|
|
281
|
+
function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
282
|
+
if (!m) return null;
|
|
283
|
+
const el = React.createElement;
|
|
284
|
+
const kids: any[] = [];
|
|
285
|
+
if (m.title != null) kids.push(el("title", { key: "t" }, m.title));
|
|
286
|
+
if (m.description != null) {
|
|
287
|
+
kids.push(el("meta", { key: "d", name: "description", content: m.description }));
|
|
288
|
+
}
|
|
289
|
+
const kw = Array.isArray(m.keywords) ? m.keywords.join(", ") : m.keywords;
|
|
290
|
+
if (kw) kids.push(el("meta", { key: "kw", name: "keywords", content: kw }));
|
|
291
|
+
if (m.robots) kids.push(el("meta", { key: "r", name: "robots", content: m.robots }));
|
|
292
|
+
if (m.canonical) {
|
|
293
|
+
kids.push(el("link", { key: "c", rel: "canonical", href: m.canonical }));
|
|
294
|
+
}
|
|
295
|
+
const og = m.openGraph;
|
|
296
|
+
if (og) {
|
|
297
|
+
if (og.title != null) kids.push(el("meta", { key: "ogt", property: "og:title", content: og.title }));
|
|
298
|
+
if (og.description != null) kids.push(el("meta", { key: "ogd", property: "og:description", content: og.description }));
|
|
299
|
+
if (og.image) kids.push(el("meta", { key: "ogi", property: "og:image", content: og.image }));
|
|
300
|
+
if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
|
|
301
|
+
if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
|
|
302
|
+
}
|
|
303
|
+
const tw = m.twitter;
|
|
304
|
+
if (tw) {
|
|
305
|
+
if (tw.card) kids.push(el("meta", { key: "twc", name: "twitter:card", content: tw.card }));
|
|
306
|
+
if (tw.title != null) kids.push(el("meta", { key: "twt", name: "twitter:title", content: tw.title }));
|
|
307
|
+
if (tw.description != null) kids.push(el("meta", { key: "twd", name: "twitter:description", content: tw.description }));
|
|
308
|
+
if (tw.image) kids.push(el("meta", { key: "twi", name: "twitter:image", content: tw.image }));
|
|
309
|
+
}
|
|
310
|
+
return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
314
|
+
|
|
315
|
+
/** Import a project-relative module, trying each common extension. */
|
|
316
|
+
async function importModule(cwd: string, relPath: string): Promise<any> {
|
|
317
|
+
const base = `${cwd}/${relPath}`;
|
|
318
|
+
let lastErr: unknown = null;
|
|
319
|
+
for (const ext of MODULE_EXTS) {
|
|
320
|
+
try {
|
|
321
|
+
return await import(`${base}${ext}`);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
lastErr = e;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
throw lastErr ?? new Error(`could not import module "${relPath}"`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Wrap a leaf element in its layout chain (leaf → root). Resolves ALL
|
|
331
|
+
* layouts first so a missing one fails before any chunk is emitted. Reused
|
|
332
|
+
* by the page render and by the not-found / error boundary render.
|
|
333
|
+
*/
|
|
334
|
+
async function buildLayoutTree(
|
|
335
|
+
cwd: string,
|
|
336
|
+
leaf: any,
|
|
337
|
+
layouts: string[] | undefined,
|
|
338
|
+
props: any,
|
|
339
|
+
React: any,
|
|
340
|
+
): Promise<any> {
|
|
341
|
+
if (!layouts || layouts.length === 0) return leaf;
|
|
342
|
+
const layoutComps: any[] = [];
|
|
343
|
+
for (const layoutPath of layouts) {
|
|
344
|
+
let lMod: any;
|
|
345
|
+
try {
|
|
346
|
+
lMod = await importModule(cwd, layoutPath);
|
|
347
|
+
} catch {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const LayoutComp = lMod.default ?? lMod.Layout ?? lMod.layout;
|
|
353
|
+
if (typeof LayoutComp !== "function") {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`layout "${layoutPath}" has no default export (or named export "Layout")`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
layoutComps.push(LayoutComp);
|
|
359
|
+
}
|
|
360
|
+
let tree = leaf;
|
|
361
|
+
for (let i = layoutComps.length - 1; i >= 0; i--) {
|
|
362
|
+
tree = React.createElement(layoutComps[i], props, tree);
|
|
363
|
+
}
|
|
364
|
+
return tree;
|
|
365
|
+
}
|
|
366
|
+
|
|
72
367
|
export async function handleRenderRoute(
|
|
73
368
|
msg: RenderRouteMessage,
|
|
74
369
|
send: Send,
|
|
75
370
|
): Promise<void> {
|
|
371
|
+
// Declared OUTSIDE the try so the catch can read page-set status/
|
|
372
|
+
// cookies when turning a redirect()/notFound() throw into a response.
|
|
373
|
+
const responseState: ResponseState = { status: 200, headers: {}, cookies: [] };
|
|
374
|
+
const response = makeResponseController(responseState);
|
|
76
375
|
try {
|
|
77
376
|
// react + react-dom are USER deps. ssr-runtime.ts lives in
|
|
78
377
|
// packages/functions/src/, but the user's react install is under
|
|
@@ -116,23 +415,14 @@ export async function handleRenderRoute(
|
|
|
116
415
|
);
|
|
117
416
|
}
|
|
118
417
|
|
|
119
|
-
// Resolve the page module
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
mod = await import(`${baseName}${ext}`);
|
|
129
|
-
break;
|
|
130
|
-
} catch (e) {
|
|
131
|
-
lastErr = e;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
if (!mod) {
|
|
135
|
-
throw lastErr ?? new Error(`could not import component "${msg.component}"`);
|
|
418
|
+
// Resolve the page module (project-relative, extension-agnostic).
|
|
419
|
+
let mod: any;
|
|
420
|
+
try {
|
|
421
|
+
mod = await importModule(cwd, msg.component);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
throw e instanceof Error
|
|
424
|
+
? e
|
|
425
|
+
: new Error(`could not import component "${msg.component}"`);
|
|
136
426
|
}
|
|
137
427
|
const Component = mod.default ?? mod.Page ?? mod.page;
|
|
138
428
|
if (typeof Component !== "function") {
|
|
@@ -148,52 +438,37 @@ export async function handleRenderRoute(
|
|
|
148
438
|
headers: msg.headers,
|
|
149
439
|
cookies: msg.cookies,
|
|
150
440
|
auth: msg.auth,
|
|
441
|
+
// Response controller — a page/layout calls response.setStatus /
|
|
442
|
+
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
443
|
+
response,
|
|
151
444
|
};
|
|
152
445
|
|
|
446
|
+
// SEO metadata: static `export const metadata` or dynamic
|
|
447
|
+
// `export async function generateMetadata(props)`. Awaited before the
|
|
448
|
+
// first byte, so keep it to cheap derivations (params → title); heavy
|
|
449
|
+
// data belongs in the page body behind <Suspense>.
|
|
450
|
+
let metadata: SsrMetadata | undefined = mod.metadata;
|
|
451
|
+
if (typeof mod.generateMetadata === "function") {
|
|
452
|
+
metadata = await mod.generateMetadata(props);
|
|
453
|
+
}
|
|
454
|
+
const metaFragment = renderMetadata(React, metadata);
|
|
455
|
+
|
|
153
456
|
// Resolve the layout chain. Each layout module exports a default
|
|
154
457
|
// function that accepts the same props + `children`. Walk leaf →
|
|
155
458
|
// root: start with the page component as `tree`, then for each
|
|
156
459
|
// layout (innermost first) wrap it as the new tree. Result is
|
|
157
460
|
// the outermost layout containing all nested layouts down to
|
|
158
|
-
// the page.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
lMod = await import(`${lBase}${ext}`);
|
|
171
|
-
break;
|
|
172
|
-
} catch {
|
|
173
|
-
// try next extension
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (!lMod) {
|
|
177
|
-
throw new Error(
|
|
178
|
-
`could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
const LayoutComp =
|
|
182
|
-
lMod.default ?? lMod.Layout ?? lMod.layout;
|
|
183
|
-
if (typeof LayoutComp !== "function") {
|
|
184
|
-
throw new Error(
|
|
185
|
-
`layout "${layoutPath}" has no default export (or named export "Layout")`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
layoutMods.push(LayoutComp);
|
|
189
|
-
}
|
|
190
|
-
// Walk LEAF → ROOT (reverse iteration on the layouts array).
|
|
191
|
-
// The innermost layout wraps the page first; each outer layout
|
|
192
|
-
// wraps the result.
|
|
193
|
-
for (let i = layoutMods.length - 1; i >= 0; i--) {
|
|
194
|
-
tree = React.createElement(layoutMods[i], props, tree);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
461
|
+
// the page. The metadata fragment is the FIRST child so React hoists
|
|
462
|
+
// its <title>/<meta> into the <head> a layout renders.
|
|
463
|
+
let tree: any = metaFragment
|
|
464
|
+
? React.createElement(
|
|
465
|
+
React.Fragment,
|
|
466
|
+
null,
|
|
467
|
+
metaFragment,
|
|
468
|
+
React.createElement(Component, props),
|
|
469
|
+
)
|
|
470
|
+
: React.createElement(Component, props);
|
|
471
|
+
tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
|
|
197
472
|
const element = tree;
|
|
198
473
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
|
|
199
474
|
element,
|
|
@@ -211,11 +486,14 @@ export async function handleRenderRoute(
|
|
|
211
486
|
|
|
212
487
|
// Headers go out before the first chunk so the host can write the
|
|
213
488
|
// response head.
|
|
489
|
+
// The shell rendered without a redirect()/notFound() throw, so the
|
|
490
|
+
// page's chosen status (default 200) + headers + cookies go out now,
|
|
491
|
+
// before the first body byte.
|
|
214
492
|
send({
|
|
215
493
|
type: "response_start",
|
|
216
494
|
call_id: msg.call_id,
|
|
217
|
-
status:
|
|
218
|
-
headers:
|
|
495
|
+
status: responseState.status,
|
|
496
|
+
headers: finalizeHeaders(responseState),
|
|
219
497
|
});
|
|
220
498
|
|
|
221
499
|
// Pre-load the manifest BEFORE the React stream starts emitting
|
|
@@ -358,7 +636,38 @@ export async function handleRenderRoute(
|
|
|
358
636
|
|
|
359
637
|
send({ type: "render_done", call_id: msg.call_id });
|
|
360
638
|
} catch (err: any) {
|
|
361
|
-
//
|
|
639
|
+
// A page/layout called response.redirect() or response.notFound()
|
|
640
|
+
// during render → short-circuit to a 3xx + Location or a 404 instead
|
|
641
|
+
// of a body. Page-set cookies/headers still ride along.
|
|
642
|
+
if (err instanceof PylonRouteControl) {
|
|
643
|
+
if (err.kind === "redirect") {
|
|
644
|
+
send({
|
|
645
|
+
type: "response_start",
|
|
646
|
+
call_id: msg.call_id,
|
|
647
|
+
status: err.redirectStatus ?? 307,
|
|
648
|
+
headers: finalizeHeaders(responseState, { location: err.url ?? "/" }),
|
|
649
|
+
});
|
|
650
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// notFound() → 404 with a minimal body (until not-found.tsx wiring).
|
|
654
|
+
const body404 =
|
|
655
|
+
'<!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
|
+
send({
|
|
657
|
+
type: "response_start",
|
|
658
|
+
call_id: msg.call_id,
|
|
659
|
+
status: 404,
|
|
660
|
+
headers: finalizeHeaders(responseState),
|
|
661
|
+
});
|
|
662
|
+
send({
|
|
663
|
+
type: "render_chunk",
|
|
664
|
+
call_id: msg.call_id,
|
|
665
|
+
data: Buffer.from(body404, "utf8").toString("base64"),
|
|
666
|
+
});
|
|
667
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Real pre-first-chunk error → host returns 500.
|
|
362
671
|
send({
|
|
363
672
|
type: "error",
|
|
364
673
|
call_id: msg.call_id,
|