@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.234",
3
+ "version": "0.3.236",
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
@@ -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,
@@ -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. The component string is project-
120
- // relative without extension; try .tsx → .ts → .jsx → .js so
121
- // any of the common page-file shapes work. cwd was captured
122
- // above for the react resolver.
123
- const baseName = `${cwd}/${msg.component}`;
124
- let mod: any = null;
125
- let lastErr: unknown = null;
126
- for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
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
- let tree: any = React.createElement(Component, props);
160
- const layouts = msg.layouts ?? [];
161
- if (layouts.length > 0) {
162
- // Resolve all layouts first so we fail fast on a missing one
163
- // BEFORE we start emitting headers / chunks.
164
- const layoutMods: any[] = [];
165
- for (const layoutPath of layouts) {
166
- const lBase = `${cwd}/${layoutPath}`;
167
- let lMod: any = null;
168
- for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
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: 200,
218
- headers: { "content-type": "text/html; charset=utf-8" },
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
- // Pre-first-chunk error host returns 500.
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,