@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.218",
3
+ "version": "0.3.222",
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",
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
+ }