@pylonsync/functions 0.3.218 → 0.3.222
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/runtime.ts +20 -0
- package/src/ssr-runtime.ts +184 -0
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -210,6 +210,26 @@ function dispatch(line: string): void {
|
|
|
210
210
|
message: err?.message || String(err),
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
|
+
} else if (msg.type === "render_route") {
|
|
214
|
+
// SSR dispatch — file-based page render. Lazy-imported so
|
|
215
|
+
// projects without SSR routes don't pay the react-dom cost on
|
|
216
|
+
// startup. handleRenderRoute manages its own error frames; we
|
|
217
|
+
// still catch here so a bare throw can't kill the runtime.
|
|
218
|
+
import("./ssr-runtime")
|
|
219
|
+
.then((mod) =>
|
|
220
|
+
mod.handleRenderRoute(
|
|
221
|
+
msg as unknown as Parameters<typeof mod.handleRenderRoute>[0],
|
|
222
|
+
send,
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
.catch((err) => {
|
|
226
|
+
send({
|
|
227
|
+
type: "error",
|
|
228
|
+
call_id: (msg as unknown as { call_id: string }).call_id,
|
|
229
|
+
code: "SSR_RUNTIME_CRASH",
|
|
230
|
+
message: err?.message || String(err),
|
|
231
|
+
});
|
|
232
|
+
});
|
|
213
233
|
} else if (msg.type === "result") {
|
|
214
234
|
const res = msg as unknown as ResultMessage & { op_id?: string };
|
|
215
235
|
// Prefer op_id when the host sent it. Fall back to call_id for replies
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// SSR handler — invoked by runtime.ts when the host sends a
|
|
2
|
+
// "render_route" message. Dynamically imports the page module, calls
|
|
3
|
+
// react-dom/server.renderToReadableStream, base64-encodes chunks back
|
|
4
|
+
// over the NDJSON pipe.
|
|
5
|
+
//
|
|
6
|
+
// The whole module is loaded lazily (handleRenderRoute is awaited from
|
|
7
|
+
// the dispatch arm) so projects without SSR routes pay nothing — no
|
|
8
|
+
// react-dom dependency requirement, no startup cost.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The message payload the host sends. Matches RenderRouteMessage in
|
|
12
|
+
* crates/functions/src/protocol.rs.
|
|
13
|
+
*/
|
|
14
|
+
export interface RenderRouteMessage {
|
|
15
|
+
type: "render_route";
|
|
16
|
+
call_id: string;
|
|
17
|
+
/**
|
|
18
|
+
* Project-relative module path (e.g. "app/hello/page"). The
|
|
19
|
+
* adapter joins cwd + this + the right extension (.tsx → .ts).
|
|
20
|
+
*/
|
|
21
|
+
component: string;
|
|
22
|
+
/** The matched route pattern (e.g. `/blog/:slug`). */
|
|
23
|
+
route_path: string;
|
|
24
|
+
/** The incoming URL path (e.g. `/blog/hello-world`). */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Dynamic-segment matches keyed by name (e.g. `{slug: "hello-world"}`). */
|
|
27
|
+
params: Record<string, string>;
|
|
28
|
+
/** Parsed query string. */
|
|
29
|
+
search_params: Record<string, string>;
|
|
30
|
+
/** Lowercased header names → values. */
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
/** Parsed cookies. */
|
|
33
|
+
cookies: Record<string, string>;
|
|
34
|
+
/** Pylon auth context. */
|
|
35
|
+
auth: {
|
|
36
|
+
user_id: string | null;
|
|
37
|
+
is_admin: boolean;
|
|
38
|
+
tenant_id: string | null;
|
|
39
|
+
roles: string[];
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Send = (msg: Record<string, unknown>) => void;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Phase 1 SSR handler. Resolves the component, renders it via
|
|
47
|
+
* react-dom/server.renderToReadableStream, pumps chunks back to the
|
|
48
|
+
* host as base64-encoded NDJSON.
|
|
49
|
+
*
|
|
50
|
+
* Errors fall back to a type:"error" frame so the host can return a
|
|
51
|
+
* 500 with the error body. Mid-stream errors (after the first chunk
|
|
52
|
+
* has flushed) are uncatchable here — React's `onError` would have
|
|
53
|
+
* to feed into a separate signal, deferred to Phase 1.5.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleRenderRoute(
|
|
56
|
+
msg: RenderRouteMessage,
|
|
57
|
+
send: Send,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
// react + react-dom are USER deps. ssr-runtime.ts lives in
|
|
61
|
+
// packages/functions/src/, but the user's react install is under
|
|
62
|
+
// their project cwd. `import("react-dom/server")` in this file
|
|
63
|
+
// would resolve against pylon's own node_modules (which doesn't
|
|
64
|
+
// declare react), so we route through a Bun-resolveSync against
|
|
65
|
+
// the user's cwd.
|
|
66
|
+
const cwd = process.cwd();
|
|
67
|
+
const resolveFromUser = (spec: string): string =>
|
|
68
|
+
(Bun as any).resolveSync
|
|
69
|
+
? (Bun as any).resolveSync(spec, cwd)
|
|
70
|
+
: spec;
|
|
71
|
+
// `renderToReadableStream` is only exported from
|
|
72
|
+
// `react-dom/server.browser` (WHATWG streams), not the plain
|
|
73
|
+
// `react-dom/server` (which is Node-stream-style). Try browser
|
|
74
|
+
// first, fall back to the default entry for environments that
|
|
75
|
+
// re-route it (Next runs a custom dist).
|
|
76
|
+
let reactDomServerImport: any;
|
|
77
|
+
try {
|
|
78
|
+
// @ts-ignore — user-dep, resolved at runtime
|
|
79
|
+
reactDomServerImport = await import(
|
|
80
|
+
/* @vite-ignore */ resolveFromUser("react-dom/server.browser")
|
|
81
|
+
);
|
|
82
|
+
} catch {
|
|
83
|
+
// @ts-ignore — user-dep, resolved at runtime
|
|
84
|
+
reactDomServerImport = await import(
|
|
85
|
+
/* @vite-ignore */ resolveFromUser("react-dom/server")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
// @ts-ignore — user-dep, resolved at runtime
|
|
89
|
+
const reactImport = await import(
|
|
90
|
+
/* @vite-ignore */ resolveFromUser("react")
|
|
91
|
+
);
|
|
92
|
+
const React = reactImport.default ?? reactImport;
|
|
93
|
+
const renderToReadableStream =
|
|
94
|
+
reactDomServerImport.renderToReadableStream ??
|
|
95
|
+
reactDomServerImport.default?.renderToReadableStream;
|
|
96
|
+
if (typeof renderToReadableStream !== "function") {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"react-dom/server.browser does not export renderToReadableStream — install react@>=18 + react-dom@>=18",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve the page module. The component string is project-
|
|
103
|
+
// relative without extension; try .tsx → .ts → .jsx → .js so
|
|
104
|
+
// any of the common page-file shapes work. cwd was captured
|
|
105
|
+
// above for the react resolver.
|
|
106
|
+
const baseName = `${cwd}/${msg.component}`;
|
|
107
|
+
let mod: any = null;
|
|
108
|
+
let lastErr: unknown = null;
|
|
109
|
+
for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
|
|
110
|
+
try {
|
|
111
|
+
mod = await import(`${baseName}${ext}`);
|
|
112
|
+
break;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
lastErr = e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!mod) {
|
|
118
|
+
throw lastErr ?? new Error(`could not import component "${msg.component}"`);
|
|
119
|
+
}
|
|
120
|
+
const Component = mod.default ?? mod.Page ?? mod.page;
|
|
121
|
+
if (typeof Component !== "function") {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`component "${msg.component}" has no default export (or named export "Page")`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const props = {
|
|
128
|
+
url: msg.url,
|
|
129
|
+
params: msg.params,
|
|
130
|
+
searchParams: msg.search_params,
|
|
131
|
+
headers: msg.headers,
|
|
132
|
+
cookies: msg.cookies,
|
|
133
|
+
auth: msg.auth,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const element = React.createElement(Component, props);
|
|
137
|
+
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
|
|
138
|
+
element,
|
|
139
|
+
{
|
|
140
|
+
onError(err: unknown) {
|
|
141
|
+
// React captures render errors during the streaming render
|
|
142
|
+
// and feeds them here. Phase 1 logs to stderr; Phase 1.5
|
|
143
|
+
// sends a structured signal so the host can truncate the
|
|
144
|
+
// body + emit a debug overlay.
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.error("[ssr] renderToReadableStream onError:", err);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Headers go out before the first chunk so the host can write the
|
|
152
|
+
// response head.
|
|
153
|
+
send({
|
|
154
|
+
type: "response_start",
|
|
155
|
+
call_id: msg.call_id,
|
|
156
|
+
status: 200,
|
|
157
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const reader = stream.getReader();
|
|
161
|
+
while (true) {
|
|
162
|
+
const { value, done } = await reader.read();
|
|
163
|
+
if (done) break;
|
|
164
|
+
if (!value || value.byteLength === 0) continue;
|
|
165
|
+
// base64 in pure JS via Buffer (Bun ships it). For large
|
|
166
|
+
// pages this is O(n) per chunk; fine for Phase 1.
|
|
167
|
+
const b64 = Buffer.from(value).toString("base64");
|
|
168
|
+
send({
|
|
169
|
+
type: "render_chunk",
|
|
170
|
+
call_id: msg.call_id,
|
|
171
|
+
data: b64,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
send({ type: "render_done", call_id: msg.call_id });
|
|
175
|
+
} catch (err: any) {
|
|
176
|
+
// Pre-first-chunk error → host returns 500.
|
|
177
|
+
send({
|
|
178
|
+
type: "error",
|
|
179
|
+
call_id: msg.call_id,
|
|
180
|
+
code: err?.code ?? "SSR_RENDER_FAILED",
|
|
181
|
+
message: err?.message ?? String(err),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|