@pylonsync/functions 0.14.0 → 0.16.0
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/dist/ssr-runtime.d.ts +7 -0
- package/dist/types.d.ts +11 -2
- package/package.json +1 -1
- package/src/runtime.ts +2 -1
- package/src/ssr-page-host.test.ts +153 -0
- package/src/ssr-runtime.ts +13 -0
- package/src/types.ts +14 -2
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface RenderRouteMessage {
|
|
|
45
45
|
* this) and `url` (deprecated); the query is `search_params`.
|
|
46
46
|
*/
|
|
47
47
|
url: string;
|
|
48
|
+
/**
|
|
49
|
+
* The request's host when the server trusts it (its own URL,
|
|
50
|
+
* `PYLON_TRUSTED_HOSTS`, a ready platform/tenant domain, or loopback in
|
|
51
|
+
* dev), else "". The same value as the SSR cache key's host dimension.
|
|
52
|
+
* Absent from older hosts.
|
|
53
|
+
*/
|
|
54
|
+
host?: string;
|
|
48
55
|
/** Dynamic-segment matches keyed by name (e.g. `{slug: "hello-world"}`). */
|
|
49
56
|
params: Record<string, string>;
|
|
50
57
|
/** Parsed query string. */
|
package/dist/types.d.ts
CHANGED
|
@@ -771,11 +771,18 @@ export interface Shards {
|
|
|
771
771
|
* `params` reach the module's `init`. Actions only: a mutation's
|
|
772
772
|
* rollback cannot undo it.
|
|
773
773
|
*
|
|
774
|
+
* On an app that runs on several machines, the shard runs on the one
|
|
775
|
+
* with the most free capacity, or on `opts.machine` (a machine id, as
|
|
776
|
+
* `ShardInfo.machine` reports it). `maxInstances` counts every machine.
|
|
777
|
+
*
|
|
774
778
|
* Throws `SHARD_EXISTS` when the id is running, `SHARD_LIMIT_REACHED`
|
|
775
779
|
* at the kind's `maxInstances`, `SHARD_KIND_NOT_FOUND`,
|
|
776
|
-
* `SHARD_ID_INVALID`,
|
|
780
|
+
* `SHARD_ID_INVALID`, `SHARD_INIT_FAILED` when `init` refuses, or
|
|
781
|
+
* `SHARD_MACHINE_UNAVAILABLE` when `opts.machine` is not running.
|
|
777
782
|
*/
|
|
778
|
-
create(kind: string, shardId: string, params?: unknown
|
|
783
|
+
create(kind: string, shardId: string, params?: unknown, opts?: {
|
|
784
|
+
machine?: string;
|
|
785
|
+
}): Promise<ShardInfo>;
|
|
779
786
|
/** Stop a shard and close its subscribers' connections. Resolves to
|
|
780
787
|
* `false` when no shard has that id. Actions only. */
|
|
781
788
|
stop(shardId: string): Promise<boolean>;
|
|
@@ -799,6 +806,8 @@ export interface ShardInfo {
|
|
|
799
806
|
running: boolean;
|
|
800
807
|
/** Why the module stopped, when it trapped. */
|
|
801
808
|
error?: string;
|
|
809
|
+
/** The machine that runs it, when the app runs on several. */
|
|
810
|
+
machine?: string;
|
|
802
811
|
}
|
|
803
812
|
/** Context for query handlers (read-only).
|
|
804
813
|
*
|
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -536,13 +536,14 @@ function buildShards(callId: string): Shards {
|
|
|
536
536
|
ttl_secs: opts?.ttlSecs,
|
|
537
537
|
}) as Promise<string>;
|
|
538
538
|
},
|
|
539
|
-
async create(kind, shardId, params) {
|
|
539
|
+
async create(kind, shardId, params, opts) {
|
|
540
540
|
return rpc(callId, {
|
|
541
541
|
type: "shard_op",
|
|
542
542
|
op: "create",
|
|
543
543
|
kind,
|
|
544
544
|
id: shardId,
|
|
545
545
|
params: params ?? {},
|
|
546
|
+
...(opts?.machine ? { machine: opts.machine } : {}),
|
|
546
547
|
}) as Promise<ShardInfo>;
|
|
547
548
|
},
|
|
548
549
|
async stop(shardId) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// `props.host`: the request host when the server trusts it, else "". The Rust
|
|
2
|
+
// host computes it (crates/runtime/tests/page_host.rs covers which hosts
|
|
3
|
+
// pass); these cover what the Bun runtime does with it:
|
|
4
|
+
// - the page reads it,
|
|
5
|
+
// - the hydration payload carries it, so the browser renders the same thing,
|
|
6
|
+
// - a bucketed page's shared tail carries it too (it is in the cache key),
|
|
7
|
+
// - reading it does not stop a page from being cached,
|
|
8
|
+
// - an older host that sends no `host` gives "".
|
|
9
|
+
//
|
|
10
|
+
// ssr-runtime.ts pulls in runtime.ts (the bun runner entrypoint, which runs
|
|
11
|
+
// main() on import), so — like ssr-design-render.test.ts — each render runs in
|
|
12
|
+
// a child process and reports its frames over stderr.
|
|
13
|
+
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const SSR_RUNTIME = join(import.meta.dir, "ssr-runtime.ts");
|
|
20
|
+
const PKG_NODE_MODULES = join(import.meta.dir, "..", "node_modules");
|
|
21
|
+
|
|
22
|
+
interface Frame {
|
|
23
|
+
type: string;
|
|
24
|
+
status?: number;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
data?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeApp(pageExtra = ""): string {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-page-host-"));
|
|
31
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
32
|
+
mkdirSync(join(dir, ".pylon", "client-build"), { recursive: true });
|
|
33
|
+
symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(dir, "app", "layout.tsx"),
|
|
36
|
+
`export default function Layout({ children }: any) {
|
|
37
|
+
return <html><head><title>t</title></head><body>{children}</body></html>;
|
|
38
|
+
}
|
|
39
|
+
`,
|
|
40
|
+
);
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(dir, "app", "page.tsx"),
|
|
43
|
+
`${pageExtra}
|
|
44
|
+
export default function Page(props: any) {
|
|
45
|
+
return <p id="host">{"host=" + props.host}</p>;
|
|
46
|
+
}
|
|
47
|
+
`,
|
|
48
|
+
);
|
|
49
|
+
writeFileSync(join(dir, ".pylon", "client-build", "entry.js"), "");
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(dir, ".pylon", "client-build", "manifest.json"),
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
outdir: ".pylon/client-build",
|
|
54
|
+
public_prefix: "/_pylon/build/",
|
|
55
|
+
routes: { "app/page": { file: "entry.js", imports: [], css: [] } },
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function render(
|
|
62
|
+
dir: string,
|
|
63
|
+
host: string | undefined,
|
|
64
|
+
env: Record<string, string> = { PYLON_DEV_MODE: "1" },
|
|
65
|
+
): Promise<{ frames: Frame[]; html: string; data: any }> {
|
|
66
|
+
const msg: Record<string, unknown> = {
|
|
67
|
+
type: "render_route",
|
|
68
|
+
call_id: "c1",
|
|
69
|
+
component: "app/page",
|
|
70
|
+
layouts: ["app/layout"],
|
|
71
|
+
route_path: "/",
|
|
72
|
+
url: "/",
|
|
73
|
+
params: {},
|
|
74
|
+
search_params: {},
|
|
75
|
+
headers: { host: host ?? "ignored.example" },
|
|
76
|
+
cookies: {},
|
|
77
|
+
auth: { user_id: null, is_admin: false, tenant_id: null, roles: [] },
|
|
78
|
+
};
|
|
79
|
+
if (host !== undefined) msg.host = host;
|
|
80
|
+
const probe = `
|
|
81
|
+
import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
|
|
82
|
+
const frames = [];
|
|
83
|
+
await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
|
|
84
|
+
console.error("FRAMES " + JSON.stringify(frames));
|
|
85
|
+
setTimeout(() => process.exit(0), 50);
|
|
86
|
+
`;
|
|
87
|
+
const probePath = join(dir, `probe-${Math.random().toString(36).slice(2)}.ts`);
|
|
88
|
+
writeFileSync(probePath, probe);
|
|
89
|
+
const proc = Bun.spawn(["bun", "run", probePath], {
|
|
90
|
+
cwd: dir,
|
|
91
|
+
env: { ...process.env, ...env },
|
|
92
|
+
stdin: "pipe",
|
|
93
|
+
stdout: "pipe",
|
|
94
|
+
stderr: "pipe",
|
|
95
|
+
});
|
|
96
|
+
const [out, err] = await Promise.all([
|
|
97
|
+
new Response(proc.stdout).text(),
|
|
98
|
+
new Response(proc.stderr).text(),
|
|
99
|
+
]);
|
|
100
|
+
const code = await proc.exited;
|
|
101
|
+
const line = err.split("\n").find((l) => l.includes("FRAMES "));
|
|
102
|
+
if (code !== 0 || !line) {
|
|
103
|
+
throw new Error(`probe failed (exit ${code})\nstdout: ${out}\nstderr: ${err}`);
|
|
104
|
+
}
|
|
105
|
+
const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + "FRAMES ".length)) as Frame[];
|
|
106
|
+
const html = frames
|
|
107
|
+
.filter((f) => f.type === "render_chunk")
|
|
108
|
+
.map((f) => Buffer.from(f.data!, "base64").toString("utf8"))
|
|
109
|
+
.join("");
|
|
110
|
+
const match = html.match(/<script id="__PYLON_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
|
111
|
+
const data = match ? JSON.parse(match[1]) : null;
|
|
112
|
+
return { frames, html, data };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("the page reads props.host, and the browser gets the same value", async () => {
|
|
116
|
+
const dir = makeApp();
|
|
117
|
+
const { html, data } = await render(dir, "feedback.acme.test");
|
|
118
|
+
expect(html).toContain("host=feedback.acme.test");
|
|
119
|
+
expect(data?.props?.host).toBe("feedback.acme.test");
|
|
120
|
+
// The raw request headers still never reach the browser.
|
|
121
|
+
expect(data?.props?.headers).toEqual({});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an untrusted host arrives as the empty string", async () => {
|
|
125
|
+
const dir = makeApp();
|
|
126
|
+
const { html, data } = await render(dir, "");
|
|
127
|
+
expect(html).toContain('host=</p>');
|
|
128
|
+
expect(data?.props?.host).toBe("");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a host that predates the field sends none: the page sees \"\"", async () => {
|
|
132
|
+
const dir = makeApp();
|
|
133
|
+
const { html, data } = await render(dir, undefined);
|
|
134
|
+
expect(html).toContain('host=</p>');
|
|
135
|
+
expect(html).not.toContain("ignored.example");
|
|
136
|
+
expect(data?.props?.host).toBe("");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("reading props.host keeps a page cacheable (the host is in the cache key)", async () => {
|
|
140
|
+
const dir = makeApp("export const revalidate = 60;");
|
|
141
|
+
const { frames, html } = await render(dir, "feedback.acme.test", { PYLON_DEV_MODE: "0" });
|
|
142
|
+
expect(html).toContain("host=feedback.acme.test");
|
|
143
|
+
// The runner's internal proof that the render is shareable; the Rust host
|
|
144
|
+
// turns it into a public Cache-Control and strips it.
|
|
145
|
+
const start = frames.find((f) => f.type === "response_start");
|
|
146
|
+
expect(start?.headers?.["x-pylon-cacheable"]).toBe("60");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a bucketed page's shared tail carries the host", async () => {
|
|
150
|
+
const dir = makeApp('export const cache = "auth-bucketed";');
|
|
151
|
+
const { data } = await render(dir, "feedback.acme.test", { PYLON_DEV_MODE: "0" });
|
|
152
|
+
expect(data?.props?.host).toBe("feedback.acme.test");
|
|
153
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -66,6 +66,13 @@ export interface RenderRouteMessage {
|
|
|
66
66
|
* this) and `url` (deprecated); the query is `search_params`.
|
|
67
67
|
*/
|
|
68
68
|
url: string;
|
|
69
|
+
/**
|
|
70
|
+
* The request's host when the server trusts it (its own URL,
|
|
71
|
+
* `PYLON_TRUSTED_HOSTS`, a ready platform/tenant domain, or loopback in
|
|
72
|
+
* dev), else "". The same value as the SSR cache key's host dimension.
|
|
73
|
+
* Absent from older hosts.
|
|
74
|
+
*/
|
|
75
|
+
host?: string;
|
|
69
76
|
/** Dynamic-segment matches keyed by name (e.g. `{slug: "hello-world"}`). */
|
|
70
77
|
params: Record<string, string>;
|
|
71
78
|
/** Parsed query string. */
|
|
@@ -2195,6 +2202,8 @@ export function buildHydrationTail(args: {
|
|
|
2195
2202
|
// undefined, which is the same trap in a new place.
|
|
2196
2203
|
pathname: restProps.pathname ?? restProps.url,
|
|
2197
2204
|
url: restProps.url,
|
|
2205
|
+
// The cache key's host dimension, so uniform across a bucket entry.
|
|
2206
|
+
host: restProps.host ?? "",
|
|
2198
2207
|
params: restProps.params,
|
|
2199
2208
|
searchParams: restProps.searchParams,
|
|
2200
2209
|
auth: { signedIn: args.bucketAuth.signedIn },
|
|
@@ -3395,6 +3404,9 @@ export async function handleRenderRoute(
|
|
|
3395
3404
|
// finds nothing forever instead of failing.
|
|
3396
3405
|
pathname: msg.url,
|
|
3397
3406
|
url: msg.url,
|
|
3407
|
+
// Keyed in the cache (the host bucket), so reading it is not a
|
|
3408
|
+
// per-request read and does not veto caching.
|
|
3409
|
+
host: msg.host ?? "",
|
|
3398
3410
|
params: msg.params,
|
|
3399
3411
|
searchParams: msg.search_params,
|
|
3400
3412
|
headers: touchProxy(msg.headers as Record<string, unknown> | undefined),
|
|
@@ -3421,6 +3433,7 @@ export async function handleRenderRoute(
|
|
|
3421
3433
|
? {
|
|
3422
3434
|
pathname: msg.url,
|
|
3423
3435
|
url: msg.url,
|
|
3436
|
+
host: msg.host ?? "",
|
|
3424
3437
|
params: jsonClone(msg.params),
|
|
3425
3438
|
searchParams: jsonClone(msg.search_params),
|
|
3426
3439
|
}
|
package/src/types.ts
CHANGED
|
@@ -867,11 +867,21 @@ export interface Shards {
|
|
|
867
867
|
* `params` reach the module's `init`. Actions only: a mutation's
|
|
868
868
|
* rollback cannot undo it.
|
|
869
869
|
*
|
|
870
|
+
* On an app that runs on several machines, the shard runs on the one
|
|
871
|
+
* with the most free capacity, or on `opts.machine` (a machine id, as
|
|
872
|
+
* `ShardInfo.machine` reports it). `maxInstances` counts every machine.
|
|
873
|
+
*
|
|
870
874
|
* Throws `SHARD_EXISTS` when the id is running, `SHARD_LIMIT_REACHED`
|
|
871
875
|
* at the kind's `maxInstances`, `SHARD_KIND_NOT_FOUND`,
|
|
872
|
-
* `SHARD_ID_INVALID`,
|
|
876
|
+
* `SHARD_ID_INVALID`, `SHARD_INIT_FAILED` when `init` refuses, or
|
|
877
|
+
* `SHARD_MACHINE_UNAVAILABLE` when `opts.machine` is not running.
|
|
873
878
|
*/
|
|
874
|
-
create(
|
|
879
|
+
create(
|
|
880
|
+
kind: string,
|
|
881
|
+
shardId: string,
|
|
882
|
+
params?: unknown,
|
|
883
|
+
opts?: { machine?: string },
|
|
884
|
+
): Promise<ShardInfo>;
|
|
875
885
|
|
|
876
886
|
/** Stop a shard and close its subscribers' connections. Resolves to
|
|
877
887
|
* `false` when no shard has that id. Actions only. */
|
|
@@ -900,6 +910,8 @@ export interface ShardInfo {
|
|
|
900
910
|
running: boolean;
|
|
901
911
|
/** Why the module stopped, when it trapped. */
|
|
902
912
|
error?: string;
|
|
913
|
+
/** The machine that runs it, when the app runs on several. */
|
|
914
|
+
machine?: string;
|
|
903
915
|
}
|
|
904
916
|
|
|
905
917
|
/** Context for query handlers (read-only).
|