@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.
- package/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/vite-plugin.ts
DELETED
|
@@ -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
|
-
}
|