@pyreon/zero 0.12.13 → 0.12.14

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.
@@ -1,6 +1,7 @@
1
+ import { readFile } from 'node:fs/promises'
1
2
  import { existsSync, readdirSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
- import type { Plugin } from 'vite'
4
+ import type { Plugin, ViteDevServer } from 'vite'
4
5
  import { generateApiRouteModule } from './api-routes'
5
6
  import { resolveConfig } from './config'
6
7
 
@@ -20,6 +21,21 @@ function scanPyreonPackages(root: string): string[] {
20
21
  return []
21
22
  }
22
23
  }
24
+
25
+ /**
26
+ * Resolve a package that isn't at the app's top-level `node_modules` but is
27
+ * nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
28
+ * to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
29
+ * so `ssrLoadModule` works without requiring the app to declare it as a
30
+ * direct dep.
31
+ */
32
+ function resolveNestedPackage(root: string, name: string): string | undefined {
33
+ const direct = join(root, 'node_modules', name)
34
+ if (existsSync(direct)) return direct
35
+ const nested = join(root, 'node_modules', '@pyreon', 'zero', 'node_modules', name)
36
+ if (existsSync(nested)) return nested
37
+ return undefined
38
+ }
23
39
  import { matchPattern } from "./entry-server";
24
40
  import { renderErrorOverlay } from "./error-overlay";
25
41
  import {
@@ -111,6 +127,41 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
111
127
  },
112
128
 
113
129
  configureServer(server) {
130
+ // Dev-mode SSR middleware — for mode: "ssr", actually render each
131
+ // matched route server-side instead of serving the SPA shell.
132
+ // Runs BEFORE the 404 handler so matched routes are SSR'd and
133
+ // unmatched ones fall through to the 404 handler.
134
+ if (config.mode === "ssr") {
135
+ server.middlewares.use((req, res, next) => {
136
+ const accept = req.headers.accept ?? "";
137
+ if (!accept.includes("text/html") && !accept.includes("*/*"))
138
+ return next();
139
+ const pathname = req.url?.split("?")[0] ?? "/";
140
+ if (pathname.startsWith("/@") || pathname.startsWith("/__"))
141
+ return next();
142
+ if (/\.\w+$/.test(pathname)) return next();
143
+
144
+ renderSsr(server, root, req.originalUrl ?? pathname, pathname).then(
145
+ (result) => {
146
+ if (result === null) return next();
147
+ res.statusCode = 200;
148
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
149
+ res.setHeader("Content-Length", Buffer.byteLength(result));
150
+ res.end(result);
151
+ },
152
+ (err: unknown) => {
153
+ const error = err instanceof Error ? err : new Error(String(err));
154
+ server.ssrFixStacktrace(error);
155
+ const html = renderErrorOverlay(error);
156
+ res.statusCode = 500;
157
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
158
+ res.setHeader("Content-Length", Buffer.byteLength(html));
159
+ res.end(html);
160
+ },
161
+ );
162
+ });
163
+ }
164
+
114
165
  // 404 handler — check if the requested path matches any route.
115
166
  // If not, render the nearest _404.tsx component with a 404 status.
116
167
  // Uses a sync wrapper that calls the async handler, since Connect
@@ -134,7 +185,7 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
134
185
  },
135
186
  (err) => {
136
187
  // oxlint-disable-next-line no-console
137
- console.error('[zero] Error in 404 handler:', err);
188
+ console.error('[Pyreon] Error in 404 handler:', err);
138
189
  next();
139
190
  },
140
191
  );
@@ -210,9 +261,33 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
210
261
  const root = userConfig.root ?? process.cwd()
211
262
  const pyreonExclude = scanPyreonPackages(root)
212
263
 
264
+ // `@pyreon/runtime-server` is only imported by zero's dev SSR
265
+ // middleware and the production server entry — apps rarely list it
266
+ // as a direct dep. Resolve it to the copy nested under zero so
267
+ // `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
268
+ const runtimeServerAlias = resolveNestedPackage(
269
+ root,
270
+ "@pyreon/runtime-server",
271
+ )
272
+
213
273
  return {
214
274
  resolve: {
215
275
  conditions: ['bun'],
276
+ ...(runtimeServerAlias
277
+ ? { alias: { '@pyreon/runtime-server': runtimeServerAlias } }
278
+ : {}),
279
+ },
280
+ // Vite's SSR module graph has its own resolver that defaults to the
281
+ // "node" condition — which would pick the built `lib/index.js` for
282
+ // every `@pyreon/*` package and bypass workspace source edits. Mirror
283
+ // the client-side "bun" condition + alias so dev SSR uses `src/`.
284
+ ssr: {
285
+ resolve: {
286
+ conditions: ['bun'],
287
+ ...(runtimeServerAlias
288
+ ? { alias: { '@pyreon/runtime-server': runtimeServerAlias } }
289
+ : {}),
290
+ },
216
291
  },
217
292
  optimizeDeps: {
218
293
  exclude: pyreonExclude,
@@ -267,6 +342,133 @@ async function handle404(
267
342
  return true;
268
343
  }
269
344
 
345
+ /**
346
+ * Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
347
+ * if the URL doesn't match any known route (caller falls through to the 404
348
+ * middleware). Mirrors the production `createServer` flow:
349
+ * 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
350
+ * 2. Create a per-request router bound to the request URL
351
+ * 3. Pre-run loaders for the matched route(s)
352
+ * 4. Render app tree with head tag collection
353
+ * 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
354
+ * 6. Inject everything into the user's transformed index.html (so Vite
355
+ * still gets a chance to inject its HMR client + JSX runtime prelude)
356
+ */
357
+ async function renderSsr(
358
+ server: ViteDevServer,
359
+ root: string,
360
+ originalUrl: string,
361
+ pathname: string,
362
+ ): Promise<string | null> {
363
+ // Pattern check FIRST — otherwise SSR would try (and likely crash) on
364
+ // asset paths that happened to accept text/html (e.g. curl-style).
365
+ const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
366
+ const routes = routesMod.routes as Array<{
367
+ path?: string;
368
+ children?: unknown[];
369
+ }>;
370
+ const patterns = flattenRoutePatterns(routes);
371
+ if (!patterns.some((pattern) => matchPattern(pattern, pathname))) {
372
+ return null;
373
+ }
374
+
375
+ // Read + transform index.html (Vite injects the HMR client / JSX prelude).
376
+ let template = await readFile(join(root, "index.html"), "utf-8");
377
+ template = await server.transformIndexHtml(originalUrl, template);
378
+
379
+ // Framework modules load through Vite's SSR module graph so user code (which
380
+ // imports the same packages) shares a single module instance — otherwise two
381
+ // copies of `@pyreon/router` would hold separate `RouterContext` IDs and
382
+ // `useContext` in RouterLink would miss the RouterProvider's value.
383
+ // `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
384
+ // `config()` hook registers an alias that points it at the copy under
385
+ // zero's own `node_modules` — same path → same Vite module → same instance.
386
+ const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
387
+ [
388
+ server.ssrLoadModule("@pyreon/core") as Promise<
389
+ typeof import("@pyreon/core")
390
+ >,
391
+ server.ssrLoadModule("@pyreon/head") as Promise<
392
+ typeof import("@pyreon/head")
393
+ >,
394
+ server.ssrLoadModule("@pyreon/head/ssr") as Promise<
395
+ typeof import("@pyreon/head/ssr")
396
+ >,
397
+ server.ssrLoadModule("@pyreon/router") as Promise<
398
+ typeof import("@pyreon/router")
399
+ >,
400
+ server.ssrLoadModule("@pyreon/runtime-server") as Promise<
401
+ typeof import("@pyreon/runtime-server")
402
+ >,
403
+ ],
404
+ );
405
+
406
+ // Build the SAME app tree the client will hydrate against. `entry-client`
407
+ // imports `layout` from `_layout.tsx` and passes it explicitly to
408
+ // `startClient` → `createApp`. We mirror that here: discover the user's
409
+ // `_layout` (if present) via Vite's SSR module graph and pass it along.
410
+ // Without this, SSR renders a different tree (no outer Layout wrapper)
411
+ // and hydration mismatches at the very first nesting level — cascading
412
+ // into duplicated mounts of every section below.
413
+ let userLayout: unknown
414
+ for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
415
+ try {
416
+ const layoutMod = (await server.ssrLoadModule(
417
+ `/src/routes/_layout.${ext}`,
418
+ )) as { layout?: unknown; default?: unknown }
419
+ userLayout = layoutMod.layout ?? layoutMod.default
420
+ if (userLayout) break
421
+ } catch {
422
+ // Try the next extension. If none exist, createApp uses DefaultLayout.
423
+ }
424
+ }
425
+
426
+ // Use zero's own `createApp` rather than reassembling the tree by hand —
427
+ // guarantees server and client agree on every wrapper component (any
428
+ // future change to the App tree only needs to happen in one place).
429
+ // Load via `ssrLoadModule` so app.ts shares Vite's SSR module graph with
430
+ // the user's code: both end up importing the SAME `@pyreon/router` /
431
+ // `@pyreon/core` / `@pyreon/head` instances, so contexts (RouterContext,
432
+ // HeadContext, etc.) match between provider and consumer. A direct Node
433
+ // `import("./app")` would resolve those packages via Node's module graph,
434
+ // producing duplicate context registries that never connect.
435
+ const appMod = (await server.ssrLoadModule(
436
+ "@pyreon/zero/server",
437
+ )) as typeof import("./server")
438
+ type CreateAppLayout = NonNullable<
439
+ Parameters<typeof appMod.createApp>[0]["layout"]
440
+ >
441
+ const { App, router: routerInst } = appMod.createApp({
442
+ routes: routes as import("@pyreon/router").RouteRecord[],
443
+ routerMode: "history",
444
+ url: pathname,
445
+ ...(userLayout ? { layout: userLayout as CreateAppLayout } : {}),
446
+ })
447
+
448
+ // `preload` loads lazy route components AND runs loaders for `pathname` so
449
+ // the synchronous render pass produces final HTML — no loading fallbacks,
450
+ // no `useLoaderData() === undefined`.
451
+ await routerInst.preload(pathname);
452
+
453
+ return runtimeServer.runWithRequestContext(async () => {
454
+ const app = core.h(App as Parameters<typeof core.h>[0], null);
455
+
456
+ const { html: appHtml, head } = await headSsr.renderWithHead(app);
457
+ const loaderData = routerPkg.serializeLoaderData(
458
+ routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
459
+ );
460
+ const hasData = loaderData && Object.keys(loaderData).length > 0;
461
+ const loaderScript = hasData
462
+ ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}</script>`
463
+ : "";
464
+
465
+ return template
466
+ .replace("<!--pyreon-head-->", head)
467
+ .replace("<!--pyreon-app-->", appHtml)
468
+ .replace("<!--pyreon-scripts-->", loaderScript);
469
+ });
470
+ }
471
+
270
472
  /** Extract all URL patterns from a nested route tree. */
271
473
  function flattenRoutePatterns(
272
474
  routes: Array<{ path?: string; children?: unknown[] }>,