@pylonsync/functions 0.3.233 → 0.3.235

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.233",
3
+ "version": "0.3.235",
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 } 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
@@ -73,6 +244,10 @@ export async function handleRenderRoute(
73
244
  msg: RenderRouteMessage,
74
245
  send: Send,
75
246
  ): Promise<void> {
247
+ // Declared OUTSIDE the try so the catch can read page-set status/
248
+ // cookies when turning a redirect()/notFound() throw into a response.
249
+ const responseState: ResponseState = { status: 200, headers: {}, cookies: [] };
250
+ const response = makeResponseController(responseState);
76
251
  try {
77
252
  // react + react-dom are USER deps. ssr-runtime.ts lives in
78
253
  // packages/functions/src/, but the user's react install is under
@@ -148,6 +323,9 @@ export async function handleRenderRoute(
148
323
  headers: msg.headers,
149
324
  cookies: msg.cookies,
150
325
  auth: msg.auth,
326
+ // Response controller — a page/layout calls response.setStatus /
327
+ // setHeader / setCookie / redirect / notFound to shape the reply.
328
+ response,
151
329
  };
152
330
 
153
331
  // Resolve the layout chain. Each layout module exports a default
@@ -211,11 +389,14 @@ export async function handleRenderRoute(
211
389
 
212
390
  // Headers go out before the first chunk so the host can write the
213
391
  // response head.
392
+ // The shell rendered without a redirect()/notFound() throw, so the
393
+ // page's chosen status (default 200) + headers + cookies go out now,
394
+ // before the first body byte.
214
395
  send({
215
396
  type: "response_start",
216
397
  call_id: msg.call_id,
217
- status: 200,
218
- headers: { "content-type": "text/html; charset=utf-8" },
398
+ status: responseState.status,
399
+ headers: finalizeHeaders(responseState),
219
400
  });
220
401
 
221
402
  // Pre-load the manifest BEFORE the React stream starts emitting
@@ -358,7 +539,38 @@ export async function handleRenderRoute(
358
539
 
359
540
  send({ type: "render_done", call_id: msg.call_id });
360
541
  } catch (err: any) {
361
- // Pre-first-chunk error host returns 500.
542
+ // A page/layout called response.redirect() or response.notFound()
543
+ // during render → short-circuit to a 3xx + Location or a 404 instead
544
+ // of a body. Page-set cookies/headers still ride along.
545
+ if (err instanceof PylonRouteControl) {
546
+ if (err.kind === "redirect") {
547
+ send({
548
+ type: "response_start",
549
+ call_id: msg.call_id,
550
+ status: err.redirectStatus ?? 307,
551
+ headers: finalizeHeaders(responseState, { location: err.url ?? "/" }),
552
+ });
553
+ send({ type: "render_done", call_id: msg.call_id });
554
+ return;
555
+ }
556
+ // notFound() → 404 with a minimal body (until not-found.tsx wiring).
557
+ const body404 =
558
+ '<!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
+ send({
560
+ type: "response_start",
561
+ call_id: msg.call_id,
562
+ status: 404,
563
+ headers: finalizeHeaders(responseState),
564
+ });
565
+ send({
566
+ type: "render_chunk",
567
+ call_id: msg.call_id,
568
+ data: Buffer.from(body404, "utf8").toString("base64"),
569
+ });
570
+ send({ type: "render_done", call_id: msg.call_id });
571
+ return;
572
+ }
573
+ // Real pre-first-chunk error → host returns 500.
362
574
  send({
363
575
  type: "error",
364
576
  call_id: msg.call_id,