@pylonsync/functions 0.3.248 → 0.3.249
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/runtime.ts +19 -1
- package/src/ssr-client-bundler.ts +46 -0
- package/src/ssr-form-runtime.ts +188 -0
- package/src/ssr-runtime.ts +64 -8
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -236,6 +236,24 @@ function dispatch(line: string): void {
|
|
|
236
236
|
String(err),
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
|
+
} else if (msg.type === "handle_form") {
|
|
240
|
+
// route.ts form/method handler (#276) — lazy-imported like SSR so
|
|
241
|
+
// projects without route handlers pay nothing on startup.
|
|
242
|
+
import("./ssr-form-runtime")
|
|
243
|
+
.then((mod) =>
|
|
244
|
+
mod.handleForm(
|
|
245
|
+
msg as unknown as Parameters<typeof mod.handleForm>[0],
|
|
246
|
+
send,
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
.catch((err) => {
|
|
250
|
+
send({
|
|
251
|
+
type: "error",
|
|
252
|
+
call_id: (msg as unknown as { call_id: string }).call_id,
|
|
253
|
+
code: "SSR_FORM_RUNTIME_CRASH",
|
|
254
|
+
message: err?.message || String(err),
|
|
255
|
+
});
|
|
256
|
+
});
|
|
239
257
|
} else if (msg.type === "bundle_client") {
|
|
240
258
|
// Hydration — build the client-side bundle once and report
|
|
241
259
|
// the path back. Lazy-imported for the same reason as SSR.
|
|
@@ -426,7 +444,7 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
|
|
|
426
444
|
return reader;
|
|
427
445
|
}
|
|
428
446
|
|
|
429
|
-
function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
|
|
447
|
+
export function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
|
|
430
448
|
// Drop the reader's `unsafe` shortcut before spreading — the
|
|
431
449
|
// writer needs its own (which we attach below). Without this
|
|
432
450
|
// strip, `writer.unsafe` would be a DbReader and the type
|
|
@@ -510,6 +510,52 @@ function installNavHandlers() {
|
|
|
510
510
|
window.addEventListener("popstate", () => {
|
|
511
511
|
navigate(location.pathname + location.search, { push: false });
|
|
512
512
|
});
|
|
513
|
+
// Progressive-enhancement form submit (#276): intercept <form data-pylon-form>
|
|
514
|
+
// so the page doesn't full-reload. fetch the same route.ts endpoint, follow
|
|
515
|
+
// the handler's redirect, and swap the destination in via navigate(). Without
|
|
516
|
+
// JS this listener never runs and the browser submits natively (POST →
|
|
517
|
+
// handler → 303 → GET) — identical result, just a full navigation.
|
|
518
|
+
document.addEventListener("submit", (e) => {
|
|
519
|
+
if (e.defaultPrevented) return;
|
|
520
|
+
const form = e.target;
|
|
521
|
+
if (!form || form.tagName !== "FORM") return;
|
|
522
|
+
if (!form.hasAttribute("data-pylon-form")) return;
|
|
523
|
+
const action = form.getAttribute("action");
|
|
524
|
+
if (!action) return;
|
|
525
|
+
// Off-origin actions / new-tab targets → let the browser submit.
|
|
526
|
+
if (action.startsWith("http://") || action.startsWith("https://") || action.startsWith("//")) return;
|
|
527
|
+
const tgt = form.getAttribute("target");
|
|
528
|
+
if (tgt && tgt !== "" && tgt !== "_self") return;
|
|
529
|
+
const method = (form.getAttribute("method") || "post").toUpperCase();
|
|
530
|
+
e.preventDefault();
|
|
531
|
+
// urlencoded body (matches the server's parser; files use the native path).
|
|
532
|
+
const body = new URLSearchParams();
|
|
533
|
+
const fd = new FormData(form);
|
|
534
|
+
fd.forEach((v, k) => {
|
|
535
|
+
if (typeof v === "string") body.append(k, v);
|
|
536
|
+
});
|
|
537
|
+
fetch(action, {
|
|
538
|
+
method,
|
|
539
|
+
body,
|
|
540
|
+
credentials: "same-origin",
|
|
541
|
+
headers: { Accept: "text/html" },
|
|
542
|
+
})
|
|
543
|
+
.then((res) => {
|
|
544
|
+
// fetch followed the handler's 303 → res.url is the destination page.
|
|
545
|
+
// Drive a client navigation to it (re-renders without a full reload).
|
|
546
|
+
const dest = new URL(res.url || action, location.href);
|
|
547
|
+
if (dest.origin === location.origin) {
|
|
548
|
+
navigate(dest.pathname + dest.search);
|
|
549
|
+
} else {
|
|
550
|
+
window.location.href = dest.href;
|
|
551
|
+
}
|
|
552
|
+
})
|
|
553
|
+
.catch(() => {
|
|
554
|
+
// Network/abort — fall back to a native submit so the user isn't stuck.
|
|
555
|
+
form.removeAttribute("data-pylon-form");
|
|
556
|
+
form.submit();
|
|
557
|
+
});
|
|
558
|
+
});
|
|
513
559
|
}
|
|
514
560
|
|
|
515
561
|
// Expose for <Link> component prefetch.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// route.ts form/method handler runtime (#276). Invoked by runtime.ts when the
|
|
2
|
+
// host sends a "handle_form" message. Imports the route.ts module, picks the
|
|
3
|
+
// handler by HTTP method, runs it with the parsed form + a read/write `db` +
|
|
4
|
+
// the SsrResponse controller, and replies over the SAME response_start /
|
|
5
|
+
// render_done protocol a render uses. A handler's common job is
|
|
6
|
+
// POST-redirect-GET: write something, then `response.redirect("/x?ok=1")`
|
|
7
|
+
// (303 by default here) so the no-JS browser follows with a GET.
|
|
8
|
+
import {
|
|
9
|
+
makeResponseController,
|
|
10
|
+
PylonRouteControl,
|
|
11
|
+
finalizeHeaders,
|
|
12
|
+
importModule,
|
|
13
|
+
type ResponseState,
|
|
14
|
+
} from "./ssr-runtime";
|
|
15
|
+
import { buildDbWriter } from "./runtime";
|
|
16
|
+
|
|
17
|
+
/** Matches HandleFormMessage in crates/functions/src/protocol.rs. */
|
|
18
|
+
export interface HandleFormMessage {
|
|
19
|
+
type: "handle_form";
|
|
20
|
+
call_id: string;
|
|
21
|
+
component: string;
|
|
22
|
+
route_path: string;
|
|
23
|
+
method: string;
|
|
24
|
+
url: string;
|
|
25
|
+
params: Record<string, string>;
|
|
26
|
+
search_params: Record<string, string>;
|
|
27
|
+
form: Record<string, string | string[]>;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
cookies: Record<string, string>;
|
|
30
|
+
auth: {
|
|
31
|
+
user_id: string | null;
|
|
32
|
+
is_admin: boolean;
|
|
33
|
+
tenant_id: string | null;
|
|
34
|
+
roles: string[];
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Send = (msg: Record<string, unknown>) => void;
|
|
39
|
+
|
|
40
|
+
/** Parsed form fields. Mirrors URLSearchParams get/getAll/has semantics. */
|
|
41
|
+
export interface FormFields {
|
|
42
|
+
/** First value for `name`, or null. */
|
|
43
|
+
get(name: string): string | null;
|
|
44
|
+
/** All values for `name` (empty array if none). */
|
|
45
|
+
getAll(name: string): string[];
|
|
46
|
+
has(name: string): boolean;
|
|
47
|
+
/** Raw map: name → value | values. */
|
|
48
|
+
readonly fields: Record<string, string | string[]>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeFormFields(raw: Record<string, string | string[]>): FormFields {
|
|
52
|
+
return {
|
|
53
|
+
get(name) {
|
|
54
|
+
const v = raw[name];
|
|
55
|
+
if (v == null) return null;
|
|
56
|
+
return Array.isArray(v) ? (v.length > 0 ? v[0] : null) : v;
|
|
57
|
+
},
|
|
58
|
+
getAll(name) {
|
|
59
|
+
const v = raw[name];
|
|
60
|
+
if (v == null) return [];
|
|
61
|
+
return Array.isArray(v) ? v : [v];
|
|
62
|
+
},
|
|
63
|
+
has(name) {
|
|
64
|
+
return Object.prototype.hasOwnProperty.call(raw, name);
|
|
65
|
+
},
|
|
66
|
+
fields: raw,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const HANDLER_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
|
71
|
+
const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
|
|
72
|
+
const isDev = () =>
|
|
73
|
+
process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
|
|
74
|
+
|
|
75
|
+
export async function handleForm(
|
|
76
|
+
msg: HandleFormMessage,
|
|
77
|
+
send: Send,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const cwd = process.cwd();
|
|
80
|
+
// Default 303 See Other — the correct POST-redirect-GET status for a form
|
|
81
|
+
// (307 would re-issue the POST to the redirect target).
|
|
82
|
+
const responseState: ResponseState = { status: 303, headers: {}, cookies: [] };
|
|
83
|
+
const response = makeResponseController(responseState, 303);
|
|
84
|
+
const req = {
|
|
85
|
+
form: makeFormFields(msg.form ?? {}),
|
|
86
|
+
params: msg.params,
|
|
87
|
+
searchParams: msg.search_params,
|
|
88
|
+
auth: msg.auth,
|
|
89
|
+
cookies: msg.cookies,
|
|
90
|
+
headers: msg.headers,
|
|
91
|
+
// Read+write DB handle (mutation-shaped; the host answers writes against a
|
|
92
|
+
// broadcast-capable store). serverData (read-only) isn't given — a handler
|
|
93
|
+
// writes via `db` and the developer enforces trust with `req.auth`.
|
|
94
|
+
db: buildDbWriter(msg.call_id),
|
|
95
|
+
response,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let mod: any;
|
|
99
|
+
try {
|
|
100
|
+
mod = await importModule(cwd, msg.component);
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
send({
|
|
103
|
+
type: "error",
|
|
104
|
+
call_id: msg.call_id,
|
|
105
|
+
code: "SSR_FORM_IMPORT_FAILED",
|
|
106
|
+
message: isDev() && e?.stack ? String(e.stack) : e?.message ?? String(e),
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const method = (msg.method || "POST").toUpperCase();
|
|
112
|
+
const handler = mod[method];
|
|
113
|
+
if (typeof handler !== "function") {
|
|
114
|
+
// 405 — advertise the methods this route.ts actually exports.
|
|
115
|
+
const allow = HANDLER_METHODS.filter(
|
|
116
|
+
(m) => typeof mod[m] === "function",
|
|
117
|
+
).join(", ");
|
|
118
|
+
send({
|
|
119
|
+
type: "response_start",
|
|
120
|
+
call_id: msg.call_id,
|
|
121
|
+
status: 405,
|
|
122
|
+
headers: {
|
|
123
|
+
"content-type": "text/plain; charset=utf-8",
|
|
124
|
+
...(allow ? { allow } : {}),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
send({
|
|
128
|
+
type: "render_chunk",
|
|
129
|
+
call_id: msg.call_id,
|
|
130
|
+
data: b64(`Method ${method} not allowed`),
|
|
131
|
+
});
|
|
132
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await handler(req);
|
|
138
|
+
// No redirect()/notFound() thrown → commit the handler's response: its
|
|
139
|
+
// status (default 303) + headers + cookies. A 303 with no explicit
|
|
140
|
+
// Location redirects back to the route path (POST-redirect-GET).
|
|
141
|
+
const extra: Record<string, string> = {};
|
|
142
|
+
if (responseState.status === 303 && !responseState.headers["location"]) {
|
|
143
|
+
extra["location"] = msg.route_path || "/";
|
|
144
|
+
}
|
|
145
|
+
send({
|
|
146
|
+
type: "response_start",
|
|
147
|
+
call_id: msg.call_id,
|
|
148
|
+
status: responseState.status,
|
|
149
|
+
headers: finalizeHeaders(responseState, extra),
|
|
150
|
+
});
|
|
151
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
// response.redirect()/notFound() throw PylonRouteControl — turn into a
|
|
154
|
+
// 3xx + Location / 404, carrying any cookies the handler set first.
|
|
155
|
+
if (err instanceof PylonRouteControl) {
|
|
156
|
+
if (err.kind === "redirect") {
|
|
157
|
+
send({
|
|
158
|
+
type: "response_start",
|
|
159
|
+
call_id: msg.call_id,
|
|
160
|
+
status: err.redirectStatus ?? 303,
|
|
161
|
+
headers: finalizeHeaders(responseState, { location: err.url ?? "/" }),
|
|
162
|
+
});
|
|
163
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
send({
|
|
167
|
+
type: "response_start",
|
|
168
|
+
call_id: msg.call_id,
|
|
169
|
+
status: 404,
|
|
170
|
+
headers: finalizeHeaders(responseState),
|
|
171
|
+
});
|
|
172
|
+
send({
|
|
173
|
+
type: "render_chunk",
|
|
174
|
+
call_id: msg.call_id,
|
|
175
|
+
data: b64("Not found"),
|
|
176
|
+
});
|
|
177
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
send({
|
|
181
|
+
type: "error",
|
|
182
|
+
call_id: msg.call_id,
|
|
183
|
+
code: err?.code ?? "SSR_FORM_FAILED",
|
|
184
|
+
message:
|
|
185
|
+
isDev() && err?.stack ? String(err.stack) : err?.message ?? String(err),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -75,7 +75,7 @@ type Send = (msg: Record<string, unknown>) => void;
|
|
|
75
75
|
* it's thrown during the shell render. (Throw it OUTSIDE an error
|
|
76
76
|
* boundary — an enclosing boundary would swallow the signal.)
|
|
77
77
|
*/
|
|
78
|
-
class PylonRouteControl extends Error {
|
|
78
|
+
export class PylonRouteControl extends Error {
|
|
79
79
|
kind: "redirect" | "notFound";
|
|
80
80
|
url?: string;
|
|
81
81
|
redirectStatus?: number;
|
|
@@ -146,7 +146,7 @@ function serializeCookie(
|
|
|
146
146
|
return c;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
interface ResponseState {
|
|
149
|
+
export interface ResponseState {
|
|
150
150
|
status: number;
|
|
151
151
|
headers: Record<string, string>;
|
|
152
152
|
cookies: string[];
|
|
@@ -184,7 +184,14 @@ export interface SsrResponse {
|
|
|
184
184
|
notFound(): never;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
// `defaultRedirectStatus` is the status `response.redirect(url)` uses when the
|
|
188
|
+
// caller doesn't pass one: 307 for a page render (preserve the method on the
|
|
189
|
+
// rare redirecting GET), 303 for a route.ts form handler (POST-redirect-GET —
|
|
190
|
+
// the browser must follow with a GET, not re-POST).
|
|
191
|
+
export function makeResponseController(
|
|
192
|
+
state: ResponseState,
|
|
193
|
+
defaultRedirectStatus = 307,
|
|
194
|
+
): SsrResponse {
|
|
188
195
|
return {
|
|
189
196
|
setStatus(code) {
|
|
190
197
|
if (!Number.isInteger(code) || code < 100 || code > 599) {
|
|
@@ -204,7 +211,7 @@ function makeResponseController(state: ResponseState): SsrResponse {
|
|
|
204
211
|
setCookie(name, value, opts) {
|
|
205
212
|
state.cookies.push(serializeCookie(name, value, opts));
|
|
206
213
|
},
|
|
207
|
-
redirect(url, status =
|
|
214
|
+
redirect(url, status = defaultRedirectStatus): never {
|
|
208
215
|
assertNoControlChars(url, "redirect url");
|
|
209
216
|
if (!Number.isInteger(status) || status < 300 || status > 399) {
|
|
210
217
|
throw new Error(`pylon ssr: redirect() status must be 3xx, got ${status}`);
|
|
@@ -226,7 +233,7 @@ function makeResponseController(state: ResponseState): SsrResponse {
|
|
|
226
233
|
* into one `Set-Cookie` header each (newline is forbidden inside a
|
|
227
234
|
* cookie, so it can't be turned into header injection).
|
|
228
235
|
*/
|
|
229
|
-
function finalizeHeaders(
|
|
236
|
+
export function finalizeHeaders(
|
|
230
237
|
state: ResponseState,
|
|
231
238
|
extra?: Record<string, string>,
|
|
232
239
|
): Record<string, string> {
|
|
@@ -373,7 +380,7 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
|
373
380
|
const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
374
381
|
|
|
375
382
|
/** Import a project-relative module, trying each common extension. */
|
|
376
|
-
async function importModule(cwd: string, relPath: string): Promise<any> {
|
|
383
|
+
export async function importModule(cwd: string, relPath: string): Promise<any> {
|
|
377
384
|
const base = `${cwd}/${relPath}`;
|
|
378
385
|
let lastErr: unknown = null;
|
|
379
386
|
for (const ext of MODULE_EXTS) {
|
|
@@ -1221,13 +1228,28 @@ export async function handleRenderRoute(
|
|
|
1221
1228
|
ssrValueCache,
|
|
1222
1229
|
);
|
|
1223
1230
|
|
|
1231
|
+
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
1232
|
+
// if its output is auth-INDEPENDENT — so wrap props.auth in a Proxy that
|
|
1233
|
+
// flips `authTouched` the moment a page/layout reads it. Reading auth at
|
|
1234
|
+
// all (even for an anonymous request) opts the render OUT of caching,
|
|
1235
|
+
// because the output could differ by identity. The raw auth is restored
|
|
1236
|
+
// before serialization (so the hydration blob carries real values, and so
|
|
1237
|
+
// JSON.stringify doesn't trip the Proxy itself).
|
|
1238
|
+
let authTouched = false;
|
|
1239
|
+
const authProxy = new Proxy(msg.auth as Record<string, unknown>, {
|
|
1240
|
+
get(target, prop, receiver) {
|
|
1241
|
+
authTouched = true;
|
|
1242
|
+
return Reflect.get(target, prop, receiver);
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1224
1246
|
props = {
|
|
1225
1247
|
url: msg.url,
|
|
1226
1248
|
params: msg.params,
|
|
1227
1249
|
searchParams: msg.search_params,
|
|
1228
1250
|
headers: msg.headers,
|
|
1229
1251
|
cookies: msg.cookies,
|
|
1230
|
-
auth:
|
|
1252
|
+
auth: authProxy,
|
|
1231
1253
|
// Response controller — a page/layout calls response.setStatus /
|
|
1232
1254
|
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
1233
1255
|
response,
|
|
@@ -1338,11 +1360,45 @@ export async function handleRenderRoute(
|
|
|
1338
1360
|
// The shell rendered without a redirect()/notFound() throw, so the
|
|
1339
1361
|
// page's chosen status (default 200) + headers + cookies go out now,
|
|
1340
1362
|
// before the first body byte.
|
|
1363
|
+
//
|
|
1364
|
+
// #277 cache verdict (Stage 1, buffered path only — a streaming render
|
|
1365
|
+
// commits its head before the body resolves, so `authTouched` isn't final
|
|
1366
|
+
// yet). A render is shareable (CDN-cacheable via `public, s-maxage`) ONLY
|
|
1367
|
+
// when ALL hold: it opted in (`export const revalidate` / `dynamic:
|
|
1368
|
+
// "force-static"`), never read props.auth (authTouched), set no cookie,
|
|
1369
|
+
// isn't `force-dynamic`, and per-caller strict policies are OFF — in strict
|
|
1370
|
+
// mode serverData reads are auth-filtered, so the output isn't shareable.
|
|
1371
|
+
// We emit an INTERNAL `x-pylon-cacheable` header the host turns into a
|
|
1372
|
+
// public Cache-Control and STRIPS; its ABSENCE is the fail-closed default
|
|
1373
|
+
// (the host keeps no-cache / no-store). The 200-only guard avoids caching
|
|
1374
|
+
// an error/redirect.
|
|
1375
|
+
const revalidateSecs =
|
|
1376
|
+
typeof (mod as any).revalidate === "number" && (mod as any).revalidate > 0
|
|
1377
|
+
? Math.floor((mod as any).revalidate)
|
|
1378
|
+
: (mod as any).dynamic === "force-static"
|
|
1379
|
+
? 31536000 // a year — only a deploy invalidates a force-static page
|
|
1380
|
+
: null;
|
|
1381
|
+
const forceDynamic = (mod as any).dynamic === "force-dynamic";
|
|
1382
|
+
const strictPolicies = process.env.PYLON_STRICT_FN_POLICIES === "1";
|
|
1383
|
+
const cacheable =
|
|
1384
|
+
revalidateSecs != null &&
|
|
1385
|
+
!forceDynamic &&
|
|
1386
|
+
!authTouched &&
|
|
1387
|
+
responseState.cookies.length === 0 &&
|
|
1388
|
+
!strictPolicies &&
|
|
1389
|
+
!Loading &&
|
|
1390
|
+
responseState.status === 200;
|
|
1391
|
+
// Restore the raw auth before any serialization below (the Proxy was only
|
|
1392
|
+
// for the render-time auth-touch probe).
|
|
1393
|
+
if (props) props.auth = msg.auth;
|
|
1341
1394
|
send({
|
|
1342
1395
|
type: "response_start",
|
|
1343
1396
|
call_id: msg.call_id,
|
|
1344
1397
|
status: responseState.status,
|
|
1345
|
-
headers: finalizeHeaders(
|
|
1398
|
+
headers: finalizeHeaders(
|
|
1399
|
+
responseState,
|
|
1400
|
+
cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : {},
|
|
1401
|
+
),
|
|
1346
1402
|
});
|
|
1347
1403
|
|
|
1348
1404
|
// Pre-load the manifest BEFORE the React stream starts emitting
|