@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 +1 -1
- package/src/index.ts +4 -0
- package/src/ssr-runtime.ts +215 -3
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 } 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
|
|
@@ -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:
|
|
218
|
-
headers:
|
|
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
|
-
//
|
|
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,
|