@pylonsync/functions 0.3.280 → 0.3.282
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/ssr-form-runtime.ts +54 -3
- package/src/ssr-raw-get.test.ts +152 -0
package/package.json
CHANGED
package/src/ssr-form-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|