@pyreon/zero 0.24.5 → 0.24.6

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.
Files changed (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
@@ -1,848 +0,0 @@
1
- import { readFile } from 'node:fs/promises'
2
- import { existsSync, readdirSync } from 'node:fs'
3
- import { join } from 'node:path'
4
- import { Readable } from 'node:stream'
5
- import type { Plugin, ViteDevServer } from 'vite'
6
- import type { IncomingMessage, ServerResponse } from 'node:http'
7
- import type { ApiRouteEntry } from './api-routes'
8
- import {
9
- createApiMiddleware,
10
- generateApiRouteModule,
11
- matchApiRoute,
12
- } from './api-routes'
13
- import { resolveConfig } from './config'
14
- // Used in the dev-mode SSR catch handler to convert loader-thrown
15
- // `redirect()` errors into real HTTP redirects (302/307/308).
16
- import { getRedirectInfo } from '@pyreon/router'
17
- import { matchPattern } from './entry-server'
18
-
19
- /**
20
- * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
21
- * Returns package names to exclude from Vite's dep optimizer.
22
- */
23
- function scanPyreonPackages(root: string): string[] {
24
- const pyreonDir = join(root, 'node_modules', '@pyreon')
25
- if (!existsSync(pyreonDir)) return []
26
-
27
- try {
28
- return readdirSync(pyreonDir)
29
- .filter((name) => !name.startsWith('.'))
30
- .map((name) => `@pyreon/${name}`)
31
- } catch {
32
- return []
33
- }
34
- }
35
-
36
- /**
37
- * Resolve a package that isn't at the app's top-level `node_modules` but is
38
- * nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
39
- * to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
40
- * so `ssrLoadModule` works without requiring the app to declare it as a
41
- * direct dep.
42
- */
43
- function resolveNestedPackage(root: string, name: string): string | undefined {
44
- const direct = join(root, 'node_modules', name)
45
- if (existsSync(direct)) return direct
46
- const nested = join(root, 'node_modules', '@pyreon', 'zero', 'node_modules', name)
47
- if (existsSync(nested)) return nested
48
- return undefined
49
- }
50
- import { renderErrorOverlay } from "./error-overlay";
51
- import {
52
- generateMiddlewareModule,
53
- generateRouteModuleFromRoutes,
54
- scanRouteFiles,
55
- scanRouteFilesWithExports,
56
- } from "./fs-router";
57
- import { expandRoutesForLocales } from "./i18n-routing";
58
- import { render404Page } from "./not-found";
59
- import { ssgPlugin } from "./ssg-plugin";
60
- import type { ZeroConfig } from "./types";
61
-
62
- const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
63
- const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
64
-
65
- const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
66
- const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
67
-
68
- const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
69
- const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
70
-
71
- /**
72
- * Per-plugin-instance storage for the user-supplied ZeroConfig. Lets
73
- * downstream consumers (e.g. `@pyreon/zero-cli`'s `build` command, which
74
- * loads the user's `vite.config.ts` and inspects its plugin list)
75
- * recover the original config without us attaching internal state to
76
- * the public Plugin object via an underscore-prefixed property.
77
- *
78
- * Exported via `getZeroPluginConfig(plugin)` so the WeakMap itself
79
- * stays an implementation detail — callers can't enumerate or mutate
80
- * the table, only read by Plugin identity.
81
- */
82
- const zeroPluginConfigMap = new WeakMap<Plugin, ZeroConfig>();
83
-
84
- /**
85
- * Retrieve the `ZeroConfig` that was passed to `zeroPlugin(userConfig)`
86
- * when the plugin was created. Returns `undefined` if the argument
87
- * isn't a recognized pyreon-zero main plugin instance.
88
- */
89
- export function getZeroPluginConfig(plugin: Plugin): ZeroConfig | undefined {
90
- return zeroPluginConfigMap.get(plugin);
91
- }
92
-
93
- /**
94
- * Detects `--port` / `--port=N` / `-p N` / `-p=N` in `process.argv`.
95
- * Used by the plugin's `config()` hook to decide whether to apply the
96
- * default port — when the CLI was invoked with `--port`, the plugin
97
- * must skip its default so the CLI flag wins (see the comment at the
98
- * port-handling block in `zeroPlugin()` for the full precedence model).
99
- *
100
- * Exported for testing only (the plugin uses it internally).
101
- *
102
- * @internal
103
- */
104
- export function argvHasPortFlag(argv: readonly string[] = process.argv): boolean {
105
- for (let i = 0; i < argv.length; i++) {
106
- const a = argv[i];
107
- if (a === "--port" || a === "-p") return true;
108
- if (a !== undefined && (a.startsWith("--port=") || a.startsWith("-p=")))
109
- return true;
110
- }
111
- return false;
112
- }
113
-
114
- /**
115
- * Zero Vite plugin — adds file-based routing and zero-config conventions
116
- * on top of @pyreon/vite-plugin.
117
- *
118
- * @example
119
- * // vite.config.ts
120
- * import pyreon from "@pyreon/vite-plugin"
121
- * import zero from "@pyreon/zero"
122
- *
123
- * export default {
124
- * plugins: [pyreon(), zero()],
125
- * }
126
- */
127
- export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
128
- const config = resolveConfig(userConfig);
129
- let routesDir: string;
130
- let root: string;
131
-
132
- const mainPlugin: Plugin = {
133
- name: "pyreon-zero",
134
- enforce: "pre",
135
-
136
- configResolved(resolvedConfig) {
137
- root = resolvedConfig.root;
138
- routesDir = `${root}/src/routes`;
139
- },
140
-
141
- resolveId(id) {
142
- if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
143
- if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
144
- if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
145
- },
146
-
147
- async load(id) {
148
- if (id === RESOLVED_VIRTUAL_ROUTES_ID) {
149
- try {
150
- // Detect each file's optional exports up front so the
151
- // generator emits the optimal shape:
152
- // • lazy() for routes that only export `default` (best code splitting)
153
- // • Direct mod.loader/.guard/.meta access for routes with metadata
154
- // • No spurious IMPORT_IS_UNDEFINED warnings from Rolldown
155
- const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
156
- // PR H — fan routes into per-locale variants when `i18n` is
157
- // configured. No-op when unset; identity-returns the input
158
- // otherwise so existing apps see byte-identical output.
159
- const routes = config.i18n
160
- ? expandRoutesForLocales(baseRoutes, config.i18n)
161
- : baseRoutes;
162
- // SSG mode: lazy() route splitting by default (parity with
163
- // SSR/SPA). Opt-out via `ssg.splitChunks: false` for tiny
164
- // sites that prefer single-chunk + instant navigation.
165
- //
166
- // Pre-2026-Q3: SSG was hardcoded to `staticImports: true`
167
- // (bundle everything). Trade-off was instant post-hydration
168
- // nav, but the initial bundle grew linearly with route
169
- // count — a 50-route docs site shipped all 50 route
170
- // components on first paint. Lazy splitting (now the
171
- // default for SSG) fixes that: only the landing route +
172
- // deps load up front, the rest fetch on navigation. See
173
- // `ssg.splitChunks` JSDoc in types.ts for the crossover-
174
- // point rationale.
175
- const ssgSplitDisabled =
176
- config.mode === "ssg" && config.ssg?.splitChunks === false;
177
- return generateRouteModuleFromRoutes(routes, routesDir, {
178
- staticImports: ssgSplitDisabled,
179
- });
180
- } catch (_err) {
181
- return `export const routes = []`;
182
- }
183
- }
184
-
185
- if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) {
186
- try {
187
- const files = await scanRouteFiles(routesDir);
188
- return generateMiddlewareModule(files, routesDir);
189
- } catch (_err) {
190
- return `export const routeMiddleware = []`;
191
- }
192
- }
193
-
194
- if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) {
195
- try {
196
- const files = await scanRouteFiles(routesDir);
197
- return generateApiRouteModule(files, routesDir);
198
- } catch (_err) {
199
- return `export const apiRoutes = []`;
200
- }
201
- }
202
- },
203
-
204
- configureServer(server) {
205
- // Dev-mode API-route middleware — production wires `createApiMiddleware`
206
- // via `createServer`, but dev had no equivalent. API requests fell
207
- // through to Vite's default 404. This middleware loads the
208
- // `virtual:zero/api-routes` module and dispatches matching requests to
209
- // the route's `GET()` / `POST()` / etc. handler. Mirrors the production
210
- // flow in `entry-server.ts` minus the ergonomic helpers (auth, cors,
211
- // etc. — those plug in via user-defined Pyreon middleware which dev
212
- // doesn't currently load; not in scope here).
213
- //
214
- // Registered FIRST so API requests don't get SSR'd or 404'd.
215
- server.middlewares.use((req, res, next) => {
216
- const pathname = req.url?.split("?")[0] ?? "/";
217
- if (pathname.startsWith("/@") || pathname.startsWith("/__"))
218
- return next();
219
- // Skip files (extension-bearing) — let Vite's static pipeline serve.
220
- if (/\.\w+$/.test(pathname)) return next();
221
-
222
- dispatchApiRoute(server, req, res).then(
223
- (handled) => {
224
- if (!handled) next();
225
- },
226
- (err: unknown) => {
227
- // oxlint-disable-next-line no-console
228
- console.error("[Pyreon] Error in dev API dispatcher:", err);
229
- next();
230
- },
231
- );
232
- });
233
-
234
- // Dev-mode SSR middleware — for mode: "ssr", actually render each
235
- // matched route server-side instead of serving the SPA shell.
236
- // Runs BEFORE the 404 handler so matched routes are SSR'd and
237
- // unmatched ones fall through to the 404 handler.
238
- if (config.mode === "ssr") {
239
- server.middlewares.use((req, res, next) => {
240
- const accept = req.headers.accept ?? "";
241
- if (!accept.includes("text/html") && !accept.includes("*/*"))
242
- return next();
243
- const pathname = req.url?.split("?")[0] ?? "/";
244
- if (pathname.startsWith("/@") || pathname.startsWith("/__"))
245
- return next();
246
- if (/\.\w+$/.test(pathname)) return next();
247
-
248
- // Build a Web Request from the Node IncomingMessage so loaders
249
- // can read cookies / auth headers via `ctx.request` and call
250
- // `redirect()` from a server-side context.
251
- const reqHost = req.headers.host ?? "localhost";
252
- const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
253
- const reqHeaders = new Headers();
254
- for (const [key, value] of Object.entries(req.headers)) {
255
- if (value !== undefined) {
256
- reqHeaders.set(
257
- key,
258
- Array.isArray(value) ? value.join(", ") : String(value),
259
- );
260
- }
261
- }
262
- const webReq = new Request(reqUrl.href, {
263
- method: req.method ?? "GET",
264
- headers: reqHeaders,
265
- });
266
-
267
- renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
268
- (result) => {
269
- if (result === null) return next();
270
- res.statusCode = result.status;
271
- res.setHeader("Content-Type", "text/html; charset=utf-8");
272
- res.setHeader("Content-Length", Buffer.byteLength(result.html));
273
- res.end(result.html);
274
- },
275
- (err: unknown) => {
276
- // Loader-thrown `redirect()` — convert to a real HTTP redirect
277
- // (302/307/308) BEFORE the layout renders. This is the dev-mode
278
- // equivalent of the production handler's redirect catch.
279
- const info = getRedirectInfo(err);
280
- if (info) {
281
- res.statusCode = info.status;
282
- res.setHeader("Location", info.url);
283
- res.end();
284
- return;
285
- }
286
- const error = err instanceof Error ? err : new Error(String(err));
287
- server.ssrFixStacktrace(error);
288
- const html = renderErrorOverlay(error);
289
- res.statusCode = 500;
290
- res.setHeader("Content-Type", "text/html; charset=utf-8");
291
- res.setHeader("Content-Length", Buffer.byteLength(html));
292
- res.end(html);
293
- },
294
- );
295
- });
296
- }
297
-
298
- // 404 handler — check if the requested path matches any route.
299
- // If not, render the nearest _404.tsx component with a 404 status.
300
- // Uses a sync wrapper that calls the async handler, since Connect
301
- // middleware does not natively support async functions.
302
- server.middlewares.use((req, res, next) => {
303
- const accept = req.headers.accept ?? "";
304
- // Accept HTML requests and wildcard requests (fetch without explicit Accept header)
305
- if (!accept.includes("text/html") && !accept.includes("*/*"))
306
- return next();
307
-
308
- const pathname = req.url?.split("?")[0] ?? "/";
309
-
310
- // Skip static assets, Vite internal requests, and file-like paths (with extensions)
311
- if (pathname.startsWith("/@") || pathname.startsWith("/__"))
312
- return next();
313
- if (/\.\w+$/.test(pathname)) return next();
314
-
315
- handle404(
316
- server,
317
- routesDir,
318
- pathname,
319
- res,
320
- root,
321
- req.originalUrl ?? pathname,
322
- ).then(
323
- (handled) => {
324
- if (!handled) next();
325
- },
326
- (err) => {
327
- // oxlint-disable-next-line no-console
328
- console.error('[Pyreon] Error in 404 handler:', err);
329
- next();
330
- },
331
- );
332
- });
333
-
334
- // SSR error overlay — intercept HTML requests and catch SSR errors
335
- // This runs as a late middleware (return function) so it wraps
336
- // Vite's own SSR handling and catches rendering failures.
337
- server.middlewares.use((req, res, next) => {
338
- const accept = req.headers.accept ?? "";
339
- if (!accept.includes("text/html")) return next();
340
-
341
- const originalEnd = res.end.bind(res);
342
- let errored = false;
343
-
344
- const handleError = (err: unknown) => {
345
- if (errored) return;
346
- errored = true;
347
- const error = err instanceof Error ? err : new Error(String(err));
348
- server.ssrFixStacktrace(error);
349
- const html = renderErrorOverlay(error);
350
- res.statusCode = 500;
351
- res.setHeader("Content-Type", "text/html; charset=utf-8");
352
- res.setHeader("Content-Length", Buffer.byteLength(html));
353
- originalEnd(html);
354
- };
355
-
356
- res.on("error", handleError);
357
-
358
- // Wrap next() in try/catch to handle both sync and async errors.
359
- // Express-style middleware may throw synchronously or pass errors
360
- // through next(err), and Vite's SSR pipeline may reject promises.
361
- try {
362
- const result = next() as unknown;
363
- // Handle async errors from Vite's SSR pipeline
364
- if (
365
- result &&
366
- typeof (result as Promise<unknown>).catch === "function"
367
- ) {
368
- (result as Promise<unknown>).catch(handleError);
369
- }
370
- } catch (err) {
371
- handleError(err);
372
- }
373
- });
374
-
375
- // Watch routes directory for changes
376
- server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
377
-
378
- // Invalidate virtual modules when route files change
379
- server.watcher.on("all", (event, path) => {
380
- if (
381
- path.startsWith(routesDir) &&
382
- (event === "add" || event === "unlink")
383
- ) {
384
- for (const resolvedId of [
385
- RESOLVED_VIRTUAL_ROUTES_ID,
386
- RESOLVED_VIRTUAL_MIDDLEWARE_ID,
387
- RESOLVED_VIRTUAL_API_ROUTES_ID,
388
- ]) {
389
- const mod = server.moduleGraph.getModuleById(resolvedId);
390
- if (mod) server.moduleGraph.invalidateModule(mod);
391
- }
392
- server.ws.send({ type: "full-reload" });
393
- }
394
- });
395
- },
396
-
397
- config(viteUserConfig) {
398
- // Discover all @pyreon/* packages installed in node_modules.
399
- // The "bun" export condition points to TS source — esbuild's
400
- // dep optimizer would compile them with the wrong JSX runtime.
401
- const cwd = viteUserConfig.root ?? process.cwd()
402
- const pyreonExclude = scanPyreonPackages(cwd)
403
-
404
- // `@pyreon/runtime-server` is only imported by zero's dev SSR
405
- // middleware and the production server entry — apps rarely list it
406
- // as a direct dep. Resolve it to the copy nested under zero so
407
- // `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
408
- const runtimeServerAlias = resolveNestedPackage(
409
- cwd,
410
- "@pyreon/runtime-server",
411
- )
412
-
413
- return {
414
- resolve: {
415
- conditions: ['bun'],
416
- ...(runtimeServerAlias
417
- ? { alias: { '@pyreon/runtime-server': runtimeServerAlias } }
418
- : {}),
419
- },
420
- // Vite's SSR module graph has its own resolver that defaults to the
421
- // "node" condition — which would pick the built `lib/index.js` for
422
- // every `@pyreon/*` package and bypass workspace source edits. Mirror
423
- // the client-side "bun" condition + alias so dev SSR uses `src/`.
424
- ssr: {
425
- resolve: {
426
- conditions: ['bun'],
427
- ...(runtimeServerAlias
428
- ? { alias: { '@pyreon/runtime-server': runtimeServerAlias } }
429
- : {}),
430
- },
431
- },
432
- optimizeDeps: {
433
- exclude: pyreonExclude,
434
- },
435
- // Port handling — the zero-canonical default is 3000 (matches
436
- // `zero dev` / `zero preview` / the runtime adapter, and
437
- // matches Next.js / Remix / Astro convention).
438
- //
439
- // Apply the default UNLESS Vite's CLI was invoked with
440
- // `--port`/`-p` (in which case the CLI flag must win — see
441
- // memory: vite cli port doesnt override plugin). PR #579
442
- // proved this empirically: returning `server: { port: 3000 }`
443
- // unconditionally clobbered `vite --port 517N --strictPort`
444
- // in the e2e playwright config and every webServer timed
445
- // out. argv detection here lets the CLI win at the source.
446
- //
447
- // Precedence (CLI > user vite.config > zero({port}) > 3000):
448
- // 1. `vite --port N` → argvHasPortFlag() === true → plugin
449
- // omits `server.port` entirely → CLI value wins
450
- // 2. User `vite.config.ts server: { port: N }` → user
451
- // config beats plugin in Vite's merge order
452
- // 3. `zero({ port: N })` → resolved into `config.port`
453
- // 4. Default 3000 — when no other source set a port
454
- //
455
- // `process.argv` is populated by the time Vite invokes the
456
- // plugin's config() hook (Vite calls plugins synchronously
457
- // during CLI bootstrap before applying inline overrides).
458
- ...(userConfig.port === undefined && argvHasPortFlag()
459
- ? {}
460
- : { server: { port: config.port } }),
461
- // Propagate `zero({ base })` to Vite's `base` config — that's
462
- // what controls asset URL rewriting in the built HTML/JS
463
- // (`<script src="/blog/assets/…">`). Pre-fix this was a
464
- // typed-but-unimplemented field: `__ZERO_BASE__` was defined
465
- // as a Vite global but no consumer existed, AND Vite's own
466
- // `base` had to be set manually in vite.config.ts. Setting
467
- // it here makes `zero({ base: '/blog/' })` the canonical
468
- // single-source-of-truth surface; the value flows through
469
- // to (a) Vite's HTML/asset URL rewriter, (b) `createRouter`
470
- // via `__ZERO_BASE__` in `startClient` / `createApp`, (c)
471
- // the SSG entry's `createApp({ base })` call.
472
- //
473
- // Vite's config-merge semantics: plugin-returned config is
474
- // the BASE; user's `vite.config.ts` top-level overrides.
475
- // So a user who sets BOTH `zero({ base: '/blog/' })` AND
476
- // `vite.config.base: '/foo/'` gets the latter — the user's
477
- // explicit override wins. The default `/` is a no-op
478
- // (matches Vite's default), so always-setting is safe.
479
- base: config.base,
480
- define: {
481
- __ZERO_MODE__: JSON.stringify(config.mode),
482
- __ZERO_BASE__: JSON.stringify(config.base),
483
- },
484
- };
485
- },
486
- };
487
-
488
- // Stash the original user config keyed by plugin identity so the CLI
489
- // (which loads vite.config.ts and inspects the plugin list) can
490
- // recover it via `getZeroPluginConfig(plugin)` without us hanging a
491
- // `_`-prefixed property off the public Plugin object.
492
- zeroPluginConfigMap.set(mainPlugin, userConfig);
493
-
494
- // SSG mode auto-wires the static-site generation hook. Other modes get
495
- // just the main plugin. The SSG plugin internally no-ops when
496
- // `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
497
- // minimal for SSR/SPA/ISR builds (one less `closeBundle` to call).
498
- return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
499
- }
500
-
501
- /**
502
- * Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
503
- * module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
504
- * the matching route's HTTP-method handler.
505
- *
506
- * Returns `true` if the API middleware handled the request (response written).
507
- * Returns `false` if no route matched (caller falls through to next middleware).
508
- *
509
- * Mirrors what `createServer` wires up in production via `createApiMiddleware`,
510
- * but adapted for Vite's connect-style middleware stack — needs a Node→Web
511
- * request adapter.
512
- */
513
- async function dispatchApiRoute(
514
- server: ViteDevServer,
515
- req: IncomingMessage,
516
- res: ServerResponse,
517
- ): Promise<boolean> {
518
- let apiRoutes: ApiRouteEntry[];
519
- try {
520
- const mod = await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID);
521
- apiRoutes = (mod.apiRoutes ?? []) as ApiRouteEntry[];
522
- } catch {
523
- return false;
524
- }
525
- if (apiRoutes.length === 0) return false;
526
-
527
- const host = req.headers.host ?? "localhost";
528
- const url = new URL(req.url ?? "/", `http://${host}`);
529
- const pathname = url.pathname;
530
-
531
- // Quick gate: only build the Web Request when the path actually matches
532
- // an api route. Reuses the same `matchApiRoute` that `createApiMiddleware`
533
- // uses internally — including catch-all `:param*` patterns from
534
- // `[...slug].ts` API routes — so the gate and the dispatcher agree on
535
- // what counts as a match. Avoids per-request body buffering for SSR /
536
- // static traffic that doesn't target an API route.
537
- const anyMatch = apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null);
538
- if (!anyMatch) return false;
539
-
540
- // Convert Node IncomingMessage → Web Request. Stream the request body
541
- // for non-GET/HEAD via `Readable.toWeb` instead of buffering — large
542
- // uploads (multipart, file POSTs) don't have to fit in memory before
543
- // the handler sees them. `duplex: 'half'` is required by the WHATWG
544
- // fetch spec when `body` is a `ReadableStream`.
545
- const method = (req.method ?? "GET").toUpperCase();
546
- const headers = new Headers();
547
- for (const [key, value] of Object.entries(req.headers)) {
548
- if (value !== undefined) {
549
- headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
550
- }
551
- }
552
- const requestInit: RequestInit & { duplex?: "half" } = { method, headers };
553
- if (method !== "GET" && method !== "HEAD") {
554
- // `Readable.toWeb` returns Node's `node:stream/web` `ReadableStream`;
555
- // `RequestInit.body` expects the DOM `ReadableStream`. They're
556
- // structurally identical at runtime but TS keeps them as separate
557
- // types — `as unknown as` is the standard bridge per TS's own
558
- // "convert to unknown first" suggestion.
559
- requestInit.body = Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>;
560
- requestInit.duplex = "half";
561
- }
562
- const webReq = new Request(url.href, requestInit);
563
-
564
- const middleware = createApiMiddleware(apiRoutes);
565
- const response = await middleware({
566
- req: webReq,
567
- url,
568
- path: pathname + url.search,
569
- headers: new Headers(),
570
- locals: {},
571
- });
572
-
573
- if (!response) return false;
574
-
575
- // Pipe the Web Response body directly to the Node response stream
576
- // instead of buffering with `arrayBuffer()`. Critical for SSE, large
577
- // downloads, and any handler that returns a `Response` constructed
578
- // from a streaming source — buffering would defeat the streaming
579
- // contract and OOM on large payloads.
580
- res.statusCode = response.status;
581
- response.headers.forEach((v, k) => {
582
- res.setHeader(k, v);
583
- });
584
- if (response.body) {
585
- // `pipe(res)` ends `res` automatically on stream completion and
586
- // auto-cancels the upstream Web ReadableStream if the client
587
- // disconnects (Node ≥18). We don't await — once the headers and
588
- // pipe are wired, the function's job is done. The connect chain
589
- // doesn't call `next()` because we resolved with `true`.
590
- // `response.body` is a DOM `ReadableStream`; `Readable.fromWeb`
591
- // expects `node:stream/web`'s `ReadableStream`. Cross-realm types
592
- // don't unify in TS — bridge via `unknown` per TS's own guidance.
593
- Readable.fromWeb(
594
- response.body as unknown as import("node:stream/web").ReadableStream,
595
- ).pipe(res);
596
- } else {
597
- res.end();
598
- }
599
- return true;
600
- }
601
-
602
- /**
603
- * 404 handler for unmatched URLs in dev. Three behaviours:
604
- *
605
- * 1. If the URL matches a real route pattern, return false (caller falls
606
- * through to the next middleware — Vite's SPA shell etc.).
607
- * 2. Otherwise, try `renderSsr`. Even for `mode: 'ssg'` / `mode: 'spa'`
608
- * apps (no upstream SSR middleware registered) this works in dev: the
609
- * router's `findNotFoundFallback` (PR L5 / M1.2) walks the routes
610
- * tree, finds a `notFoundComponent` (`_404.tsx` / `_not-found.tsx`)
611
- * attached to the deepest matching parent layout, builds a synthetic
612
- * chain `[...layouts, syntheticLeaf]`, and renderSsr produces 404
613
- * HTML INSIDE the layout's chrome — matching what `dist/404.html`
614
- * ships at build time.
615
- * 3. If renderSsr returns null (no `notFoundComponent` reachable from
616
- * any layout), fall back to a bare static HTML page so the user
617
- * gets SOMETHING.
618
- *
619
- * **Pre-fix this function ALWAYS emitted the bare static page in step 3**,
620
- * ignoring any user-provided `_404.tsx` / `_not-found.tsx`. For
621
- * `mode: 'ssr'` apps the upstream SSR middleware caught the 404 first
622
- * (so a `_404.tsx` worked there), but for `mode: 'ssg'` / `mode: 'spa'`
623
- * apps the SSR middleware never registered and unmatched URLs fell
624
- * through here directly — dev showed the bare fallback while the
625
- * SSG-built `dist/404.html` shipped the branded version. Production-
626
- * vs-dev drift; no warning.
627
- *
628
- * For `mode: 'ssr'` apps the upstream SSR middleware is still the
629
- * primary path (cheap when matched). renderSsr may be called twice on a
630
- * truly-unmatched URL (once by the upstream middleware, once here as
631
- * fallback). The duplicate cost is purely a no-op `resolveRoute` call
632
- * returning `matched: []` again — no extra render work.
633
- *
634
- * Returns true if the 404 was handled (response sent), false if the path
635
- * actually matches a route (caller continues to next middleware).
636
- */
637
- async function handle404(
638
- server: import("vite").ViteDevServer,
639
- _routesDir: string,
640
- pathname: string,
641
- res: import("http").ServerResponse,
642
- root: string,
643
- originalUrl: string,
644
- ): Promise<boolean> {
645
- const mod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
646
- const routes = mod.routes as Array<{ path?: string; children?: unknown[] }>;
647
- const patterns = flattenRoutePatterns(routes);
648
-
649
- if (patterns.some((pattern) => matchPattern(pattern, pathname))) {
650
- return false; // Route matches — not a 404
651
- }
652
-
653
- // Try the router-driven path: renderSsr → resolveRoute →
654
- // findNotFoundFallback. Returns layout-wrapped 404 HTML + status 404 if
655
- // any reachable `notFoundComponent` matches; returns null only when no
656
- // `_404.tsx` / `_not-found.tsx` exists anywhere in the routes tree.
657
- //
658
- // Try/catch protects against ssrLoadModule failures (e.g. the user's
659
- // `app.ts` has a syntax error in dev): we'd rather serve the bare
660
- // fallback than crash the 404 handler. The caller's error path catches
661
- // `next(err)` if renderSsr rejects in a way we can't recover from.
662
- try {
663
- const result = await renderSsr(server, root, originalUrl, pathname);
664
- if (result !== null) {
665
- res.statusCode = result.status;
666
- res.setHeader("Content-Type", "text/html; charset=utf-8");
667
- res.setHeader("Content-Length", Buffer.byteLength(result.html));
668
- res.end(result.html);
669
- return true;
670
- }
671
- } catch {
672
- // Fall through to bare HTML below.
673
- }
674
-
675
- // No `notFoundComponent` reachable + renderSsr returned null — emit a
676
- // minimal static page so the user gets SOMETHING. Apps that want
677
- // branded 404s should add `_404.tsx` (or `_not-found.tsx`) to their
678
- // routes tree.
679
- const html = await render404Page(undefined);
680
-
681
- res.statusCode = 404;
682
- res.setHeader("Content-Type", "text/html; charset=utf-8");
683
- res.setHeader("Content-Length", Buffer.byteLength(html));
684
- res.end(html);
685
- return true;
686
- }
687
-
688
- /**
689
- * Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
690
- * if the URL doesn't match any known route (caller falls through to the 404
691
- * middleware). Mirrors the production `createServer` flow:
692
- * 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
693
- * 2. Create a per-request router bound to the request URL
694
- * 3. Pre-run loaders for the matched route(s)
695
- * 4. Render app tree with head tag collection
696
- * 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
697
- * 6. Inject everything into the user's transformed index.html (so Vite
698
- * still gets a chance to inject its HMR client + JSX runtime prelude)
699
- */
700
- async function renderSsr(
701
- server: ViteDevServer,
702
- root: string,
703
- originalUrl: string,
704
- pathname: string,
705
- req?: Request,
706
- ): Promise<{ html: string; status: number } | null> {
707
- const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
708
- const routes = routesMod.routes as Array<{
709
- path?: string;
710
- children?: unknown[];
711
- }>;
712
-
713
- // Read + transform index.html (Vite injects the HMR client / JSX prelude).
714
- let template = await readFile(join(root, "index.html"), "utf-8");
715
- template = await server.transformIndexHtml(originalUrl, template);
716
-
717
- // Framework modules load through Vite's SSR module graph so user code (which
718
- // imports the same packages) shares a single module instance — otherwise two
719
- // copies of `@pyreon/router` would hold separate `RouterContext` IDs and
720
- // `useContext` in RouterLink would miss the RouterProvider's value.
721
- // `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
722
- // `config()` hook registers an alias that points it at the copy under
723
- // zero's own `node_modules` — same path → same Vite module → same instance.
724
- const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
725
- [
726
- server.ssrLoadModule("@pyreon/core") as Promise<
727
- typeof import("@pyreon/core")
728
- >,
729
- server.ssrLoadModule("@pyreon/head") as Promise<
730
- typeof import("@pyreon/head")
731
- >,
732
- server.ssrLoadModule("@pyreon/head/ssr") as Promise<
733
- typeof import("@pyreon/head/ssr")
734
- >,
735
- server.ssrLoadModule("@pyreon/router") as Promise<
736
- typeof import("@pyreon/router")
737
- >,
738
- server.ssrLoadModule("@pyreon/runtime-server") as Promise<
739
- typeof import("@pyreon/runtime-server")
740
- >,
741
- ],
742
- );
743
-
744
- // Don't auto-load `_layout.tsx` as the outer Layout. fs-router already
745
- // emits it as a parent route in the matched chain — wrapping createApp
746
- // with the same layout AGAIN produced a double mount: the App tree was
747
- // `<Layout><RouterView/></Layout>` AND the router's first matched
748
- // record was the layout, rendering it a second time inside RouterView.
749
- // Symptom: duplicate `<nav>`, duplicate `<div id="layout">`,
750
- // hydration mismatches, strict-mode locator violations in playwright.
751
- //
752
- // If a user genuinely needs an outer wrapper that sits ABOVE the
753
- // router (e.g. a global provider not tied to any route), they can
754
- // still pass it via `startClient({ layout })` — but the file should
755
- // NOT live under `src/routes/_layout.{ts,tsx,…}` (which fs-router
756
- // always treats as a parent route).
757
-
758
- // Use zero's own `createApp` rather than reassembling the tree by hand —
759
- // guarantees server and client agree on every wrapper component (any
760
- // future change to the App tree only needs to happen in one place).
761
- // Load via `ssrLoadModule` so app.ts shares Vite's SSR module graph with
762
- // the user's code: both end up importing the SAME `@pyreon/router` /
763
- // `@pyreon/core` / `@pyreon/head` instances, so contexts (RouterContext,
764
- // HeadContext, etc.) match between provider and consumer. A direct Node
765
- // `import("./app")` would resolve those packages via Node's module graph,
766
- // producing duplicate context registries that never connect.
767
- const appMod = (await server.ssrLoadModule(
768
- "@pyreon/zero/server",
769
- )) as typeof import("./server")
770
- const { App, router: routerInst } = appMod.createApp({
771
- routes: routes as import("@pyreon/router").RouteRecord[],
772
- routerMode: "history",
773
- url: pathname,
774
- })
775
-
776
- // `preload` loads lazy route components AND runs loaders for `pathname` so
777
- // the synchronous render pass produces final HTML — no loading fallbacks,
778
- // no `useLoaderData() === undefined`.
779
- //
780
- // M1.2 — Unmatched URLs no longer bail to a static 404 page. The router's
781
- // `resolveRoute` (PR L5) walks the route tree and, if a parent layout has
782
- // `notFoundComponent` AND the URL is under that layout's prefix, builds a
783
- // synthetic chain `[...ancestorLayouts, syntheticLeaf]` with
784
- // `isNotFound: true`. The render then produces 404 HTML INSIDE the
785
- // layout's chrome. If the routes tree has no reachable `notFoundComponent`,
786
- // `matched` stays empty — fall through to `handle404` for the static
787
- // fallback (preserves backward compat for apps without `_404.tsx`).
788
- await routerInst.preload(pathname, req);
789
-
790
- const resolved = routerInst.currentRoute() as
791
- | { matched?: unknown[]; isNotFound?: boolean }
792
- | undefined;
793
- if (!resolved?.matched || resolved.matched.length === 0) {
794
- return null;
795
- }
796
- const status = resolved.isNotFound === true ? 404 : 200;
797
-
798
- return runtimeServer.runWithRequestContext(async () => {
799
- const app = core.h(App as Parameters<typeof core.h>[0], null);
800
-
801
- const { html: appHtml, head } = await headSsr.renderWithHead(app);
802
- const loaderData = routerPkg.serializeLoaderData(
803
- routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
804
- );
805
- const hasData = loaderData && Object.keys(loaderData).length > 0;
806
- // M2.2 — safe serializer (parity with production handler / SSG entry).
807
- const loaderScript = hasData
808
- ? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}</script>`
809
- : "";
810
-
811
- const html = template
812
- .replace("<!--pyreon-head-->", head)
813
- .replace("<!--pyreon-app-->", appHtml)
814
- .replace("<!--pyreon-scripts-->", loaderScript);
815
- return { html, status };
816
- });
817
- }
818
-
819
- /**
820
- * Extract all URL patterns from a nested route tree.
821
- *
822
- * The fs-router emits ABSOLUTE paths for every route, including grandchildren —
823
- * `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
824
- * each route's `path` as-is; no prefix accumulation. Pre-fix, this function
825
- * concatenated `${prefix}${route.path}` which produced patterns like
826
- * `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
827
- * `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
828
- * — which can't match a real `/app/dashboard` request — so dev-server returned
829
- * 404 for every nested-layout route. Re-enables PR #411 specs that rely on
830
- * `/app/*` routing.
831
- */
832
- function flattenRoutePatterns(
833
- routes: Array<{ path?: string; children?: unknown[] }>,
834
- ): string[] {
835
- const patterns: string[] = [];
836
- for (const route of routes) {
837
- if (!route.path) continue;
838
- patterns.push(route.path);
839
- if (route.children) {
840
- patterns.push(
841
- ...flattenRoutePatterns(
842
- route.children as Array<{ path?: string; children?: unknown[] }>,
843
- ),
844
- );
845
- }
846
- }
847
- return patterns;
848
- }