@pylonsync/functions 0.3.280 → 0.3.281

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.280",
3
+ "version": "0.3.281",
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",
@@ -2,9 +2,18 @@
2
2
  // host sends a "handle_form" message. Imports the route.ts module, picks the
3
3
  // handler by HTTP method, runs it with the parsed form + a read/write `db` +
4
4
  // the SsrResponse controller, and replies over the SAME response_start /
5
- // render_done protocol a render uses. A handler's common job is
5
+ // render_done protocol a render uses. A non-GET handler's common job is
6
6
  // POST-redirect-GET: write something, then `response.redirect("/x?ok=1")`
7
7
  // (303 by default here) so the no-JS browser follows with a GET.
8
+ //
9
+ // A `GET` export is special: it's a RAW handler. Instead of returning void +
10
+ // shaping the reply through `response`, it returns
11
+ // { body, contentType?, status?, headers? }
12
+ // which is streamed verbatim — no React render, no hydration tail. This is the
13
+ // mechanism behind dynamic raw GET routes (RSS/Atom feeds, dynamic XML, text,
14
+ // JSON, .well-known files) — the GET analogue of `app/sitemap.ts`/`robots.ts`,
15
+ // but at an arbitrary route path. A GET handler may still throw
16
+ // `response.redirect()`/`notFound()` instead of returning a body.
8
17
  import {
9
18
  makeResponseController,
10
19
  PylonRouteControl,
@@ -67,7 +76,10 @@ function makeFormFields(raw: Record<string, string | string[]>): FormFields {
67
76
  };
68
77
  }
69
78
 
70
- const HANDLER_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
79
+ // GET is a raw handler (returns a body), the rest are form/mutation handlers
80
+ // (return void + use the response controller). All are dispatched the same way;
81
+ // the list drives the 405 `Allow` header advertising which a route.ts exports.
82
+ const HANDLER_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
71
83
  const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
72
84
  const isDev = () =>
73
85
  process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
@@ -133,8 +145,47 @@ export async function handleForm(
133
145
  return;
134
146
  }
135
147
 
148
+ // A raw GET handler returns its body; the default status is 200 (not the
149
+ // POST-redirect-GET 303 that form handlers use). The handler can still
150
+ // override via the returned object or `response.setStatus`.
151
+ if (method === "GET") {
152
+ responseState.status = 200;
153
+ }
154
+
136
155
  try {
137
- await handler(req);
156
+ const out = await handler(req);
157
+ if (method === "GET") {
158
+ // Raw GET: stream `out.body` with the handler's content-type/status/
159
+ // headers (merged with anything set via the response controller). No
160
+ // React, no hydration tail — verbatim bytes, like sitemap/robots.
161
+ const raw = (out ?? {}) as {
162
+ body?: unknown;
163
+ contentType?: string;
164
+ status?: number;
165
+ headers?: Record<string, string>;
166
+ };
167
+ const extra: Record<string, string> = {};
168
+ extra["content-type"] =
169
+ raw.contentType ??
170
+ responseState.headers["content-type"] ??
171
+ "text/plain; charset=utf-8";
172
+ for (const [k, v] of Object.entries(raw.headers ?? {})) {
173
+ extra[k.toLowerCase()] = String(v);
174
+ }
175
+ send({
176
+ type: "response_start",
177
+ call_id: msg.call_id,
178
+ status: raw.status ?? responseState.status,
179
+ headers: finalizeHeaders(responseState, extra),
180
+ });
181
+ if (raw.body != null) {
182
+ const bodyStr =
183
+ typeof raw.body === "string" ? raw.body : String(raw.body);
184
+ send({ type: "render_chunk", call_id: msg.call_id, data: b64(bodyStr) });
185
+ }
186
+ send({ type: "render_done", call_id: msg.call_id });
187
+ return;
188
+ }
138
189
  // No redirect()/notFound() thrown → commit the handler's response: its
139
190
  // status (default 303) + headers + cookies. A 303 with no explicit
140
191
  // Location redirects back to the route path (POST-redirect-GET).
@@ -0,0 +1,152 @@
1
+ // Tests for raw GET route handlers: a `route.ts` exporting `GET` returns
2
+ // { body, contentType?, status?, headers? }
3
+ // which handleForm streams verbatim (response_start / render_chunk / render_done)
4
+ // with a custom content-type and NO React render / hydration tail. This is the
5
+ // GET analogue of app/sitemap.ts → /sitemap.xml, at an arbitrary route path.
6
+ //
7
+ // ssr-form-runtime.ts imports runtime.ts, which runs main() on import (it IS the
8
+ // bun runner entrypoint), so — like runtime-db.test.ts — the handler is exercised
9
+ // in a child process with a kept-open stdin pipe. The probe calls handleForm
10
+ // directly and reports the emitted frames over stderr (main() fences stdout for
11
+ // real protocol frames).
12
+
13
+ import { expect, test } from "bun:test";
14
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ const FORM_RUNTIME = join(import.meta.dir, "ssr-form-runtime.ts");
19
+
20
+ interface Frame {
21
+ type: string;
22
+ status?: number;
23
+ headers?: Record<string, string>;
24
+ data?: string;
25
+ }
26
+
27
+ // Spawn a child that writes `app/<routeDir>/route.ts`, calls handleForm with a
28
+ // GET `handle_form` message, and prints the resulting frames on stderr.
29
+ async function runGet(
30
+ routeDir: string,
31
+ routeSrc: string,
32
+ msgOverrides: Record<string, unknown> = {},
33
+ ): Promise<Frame[]> {
34
+ const dir = mkdtempSync(join(tmpdir(), "pylon-raw-get-"));
35
+ const routeFull = join(dir, "app", routeDir);
36
+ mkdirSync(routeFull, { recursive: true });
37
+ writeFileSync(join(routeFull, "route.ts"), routeSrc);
38
+
39
+ const msg = {
40
+ type: "handle_form",
41
+ call_id: "c1",
42
+ component: `app/${routeDir}/route`,
43
+ route_path: "/r",
44
+ method: "GET",
45
+ url: "/r",
46
+ params: {},
47
+ search_params: {},
48
+ form: {},
49
+ headers: {},
50
+ cookies: {},
51
+ auth: { user_id: null, is_admin: false, tenant_id: null, roles: [] },
52
+ ...msgOverrides,
53
+ };
54
+
55
+ const probe = `
56
+ import { handleForm } from ${JSON.stringify(FORM_RUNTIME)};
57
+ const frames = [];
58
+ await handleForm(${JSON.stringify(msg)}, (m) => frames.push(m));
59
+ console.error("FRAMES " + JSON.stringify(frames));
60
+ setTimeout(() => process.exit(0), 50);
61
+ `;
62
+ const scriptPath = join(dir, "probe.ts");
63
+ writeFileSync(scriptPath, probe);
64
+
65
+ const proc = Bun.spawn(["bun", scriptPath], {
66
+ cwd: dir, // importModule resolves the component relative to cwd
67
+ stdin: "pipe", // keep main()'s readerLoop alive until the probe self-exits
68
+ stdout: "pipe",
69
+ stderr: "pipe",
70
+ });
71
+ const [stderr] = await Promise.all([
72
+ new Response(proc.stderr).text(),
73
+ new Response(proc.stdout).text(),
74
+ proc.exited,
75
+ ]);
76
+ const line = stderr.split("\n").find((l) => l.includes("FRAMES "));
77
+ if (!line) throw new Error(`probe produced no frames. stderr:\n${stderr}`);
78
+ return JSON.parse(line.slice(line.indexOf("FRAMES ") + 7)) as Frame[];
79
+ }
80
+
81
+ const decode = (f?: Frame) =>
82
+ f?.data ? Buffer.from(f.data, "base64").toString("utf8") : "";
83
+
84
+ test("GET export → 200, custom content-type + raw body, no hydration tail", async () => {
85
+ const frames = await runGet(
86
+ "appcast.xml",
87
+ `export const GET = async () => ({
88
+ body: '<?xml version="1.0"?><rss><channel><title>Yapless</title></channel></rss>',
89
+ contentType: "application/xml; charset=utf-8",
90
+ headers: { "cache-control": "public, max-age=300" },
91
+ });`,
92
+ );
93
+ const start = frames.find((f) => f.type === "response_start");
94
+ const body = decode(frames.find((f) => f.type === "render_chunk"));
95
+ expect(start?.status).toBe(200);
96
+ expect(start?.headers?.["content-type"]).toContain("application/xml");
97
+ expect(start?.headers?.["cache-control"]).toBe("public, max-age=300");
98
+ expect(body).toContain("<rss>");
99
+ expect(body.trim().endsWith("</rss>")).toBe(true);
100
+ // Critical: no hydration <script>/__PYLON_DATA__ tail (would corrupt XML).
101
+ expect(body.includes("__PYLON_DATA__")).toBe(false);
102
+ expect(body.includes("<script")).toBe(false);
103
+ expect(frames.some((f) => f.type === "render_done")).toBe(true);
104
+ });
105
+
106
+ test("async GET reads params + returns a custom status", async () => {
107
+ const frames = await runGet(
108
+ "feed/[slug]",
109
+ `export const GET = async (req) => ({
110
+ body: "feed for " + req.params.slug,
111
+ contentType: "text/plain; charset=utf-8",
112
+ status: 201,
113
+ });`,
114
+ { route_path: "/feed/:slug", url: "/feed/abc", params: { slug: "abc" } },
115
+ );
116
+ const start = frames.find((f) => f.type === "response_start");
117
+ expect(start?.status).toBe(201);
118
+ expect(decode(frames.find((f) => f.type === "render_chunk"))).toBe("feed for abc");
119
+ });
120
+
121
+ test("GET with no content-type defaults to text/plain", async () => {
122
+ const frames = await runGet("ping", `export const GET = () => ({ body: "ok" });`, {
123
+ route_path: "/ping",
124
+ url: "/ping",
125
+ });
126
+ const start = frames.find((f) => f.type === "response_start");
127
+ expect(start?.status).toBe(200);
128
+ expect(start?.headers?.["content-type"]).toContain("text/plain");
129
+ });
130
+
131
+ test("GET may response.redirect() instead of returning a body", async () => {
132
+ const frames = await runGet(
133
+ "go",
134
+ `export const GET = (req) => { req.response.redirect("/dest"); };`,
135
+ { route_path: "/go", url: "/go" },
136
+ );
137
+ const start = frames.find((f) => f.type === "response_start");
138
+ expect(start?.status).toBeGreaterThanOrEqual(300);
139
+ expect(start?.status).toBeLessThan(400);
140
+ expect(start?.headers?.["location"]).toBe("/dest");
141
+ });
142
+
143
+ test("GET on a route.ts with no GET export → 405 advertising its methods", async () => {
144
+ const frames = await runGet(
145
+ "form-only",
146
+ `export const POST = (req) => { req.response.redirect("/ok"); };`,
147
+ { route_path: "/form-only", url: "/form-only" },
148
+ );
149
+ const start = frames.find((f) => f.type === "response_start");
150
+ expect(start?.status).toBe(405);
151
+ expect(start?.headers?.["allow"]).toContain("POST");
152
+ });