@pylonsync/functions 0.3.279 → 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 +1 -1
- package/src/ssr-client-bundler.ts +23 -2
- package/src/ssr-form-runtime.ts +54 -3
- package/src/ssr-raw-get.test.ts +152 -0
package/package.json
CHANGED
|
@@ -756,9 +756,30 @@ async function _prebuiltBundle(): Promise<BuildOutput | null> {
|
|
|
756
756
|
const path = pathMod.default ?? pathMod;
|
|
757
757
|
const outdir = path.join(process.cwd(), ".pylon", "client-build");
|
|
758
758
|
const manifestPath = path.join(outdir, "manifest.json");
|
|
759
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
760
|
+
|
|
761
|
+
// Reuse a prebuilt bundle when it's explicitly marked (`.prebuilt`) OR when
|
|
762
|
+
// its manifest already targets an ABSOLUTE (CDN) `public_prefix`. The builder
|
|
763
|
+
// bakes an absolute prefix only when it pre-built for the CDN, and it
|
|
764
|
+
// published the hashed assets under THOSE exact hashes — so the runtime must
|
|
765
|
+
// serve this exact manifest verbatim (a local rebuild emits different hashes
|
|
766
|
+
// that would 404 on the CDN). The manifest is a regular file that always
|
|
767
|
+
// ships with the build; keying on it (not just the `.prebuilt` dotfile, which
|
|
768
|
+
// can be dropped in transit) makes reuse robust. A same-origin
|
|
769
|
+
// `/_pylon/build/` manifest is a normal dev/local build → don't short-circuit
|
|
770
|
+
// (let dev hot-rebuild).
|
|
759
771
|
const marker = path.join(outdir, ".prebuilt");
|
|
760
|
-
if (fs.existsSync(marker)
|
|
761
|
-
|
|
772
|
+
if (fs.existsSync(marker)) return { manifestPath, outdir };
|
|
773
|
+
try {
|
|
774
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
775
|
+
if (
|
|
776
|
+
typeof m.public_prefix === "string" &&
|
|
777
|
+
/^https?:\/\//i.test(m.public_prefix)
|
|
778
|
+
) {
|
|
779
|
+
return { manifestPath, outdir };
|
|
780
|
+
}
|
|
781
|
+
} catch {
|
|
782
|
+
/* unreadable/partial manifest → fall through and rebuild */
|
|
762
783
|
}
|
|
763
784
|
return null;
|
|
764
785
|
}
|
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
|
+
});
|