@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.248",
3
+ "version": "0.3.249",
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/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
+ }
@@ -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
- function makeResponseController(state: ResponseState): SsrResponse {
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 = 307): never {
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: msg.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(responseState),
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