@pyreon/zero 0.13.1 → 0.15.0
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/lib/api-routes-DANluJic.js +146 -0
- package/lib/client.js +3 -1
- package/lib/csp.js +19 -9
- package/lib/{fs-router-CQ7Zxeca.js → fs-router-ZebyutPa.js} +43 -6
- package/lib/image-plugin.js +4 -0
- package/lib/image.js +1 -50
- package/lib/index.js +1 -50
- package/lib/link.js +1 -49
- package/lib/script.js +1 -49
- package/lib/server.js +6 -688
- package/lib/theme.js +1 -50
- package/lib/types/i18n-routing.d.ts +4 -4
- package/lib/types/index.d.ts +23 -13
- package/lib/types/link.d.ts +3 -3
- package/lib/types/server.d.ts +28 -5
- package/lib/types/theme.d.ts +2 -2
- package/lib/vite-plugin-E4BHYvYW.js +855 -0
- package/package.json +15 -13
- package/src/app.ts +21 -1
- package/src/csp.ts +28 -12
- package/src/fs-router.ts +53 -3
- package/src/ssg-plugin.ts +366 -0
- package/src/types.ts +28 -9
- package/src/vite-plugin.ts +220 -40
- package/lib/actions.js.map +0 -1
- package/lib/ai.js.map +0 -1
- package/lib/api-routes.js.map +0 -1
- package/lib/cache.js.map +0 -1
- package/lib/client.js.map +0 -1
- package/lib/compression.js.map +0 -1
- package/lib/config.js.map +0 -1
- package/lib/cors.js.map +0 -1
- package/lib/csp.js.map +0 -1
- package/lib/env.js.map +0 -1
- package/lib/favicon.js.map +0 -1
- package/lib/font.js.map +0 -1
- package/lib/fs-router-3xzp-4Wj.js.map +0 -1
- package/lib/fs-router-CQ7Zxeca.js.map +0 -1
- package/lib/i18n-routing.js.map +0 -1
- package/lib/image-plugin.js.map +0 -1
- package/lib/image.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/link.js.map +0 -1
- package/lib/logger.js.map +0 -1
- package/lib/meta.js.map +0 -1
- package/lib/middleware.js.map +0 -1
- package/lib/og-image.js.map +0 -1
- package/lib/rate-limit.js.map +0 -1
- package/lib/script.js.map +0 -1
- package/lib/seo.js.map +0 -1
- package/lib/server.js.map +0 -1
- package/lib/testing.js.map +0 -1
- package/lib/theme.js.map +0 -1
- package/lib/types/actions.d.ts.map +0 -1
- package/lib/types/ai.d.ts.map +0 -1
- package/lib/types/api-routes.d.ts.map +0 -1
- package/lib/types/cache.d.ts.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/compression.d.ts.map +0 -1
- package/lib/types/config.d.ts.map +0 -1
- package/lib/types/cors.d.ts.map +0 -1
- package/lib/types/csp.d.ts.map +0 -1
- package/lib/types/env.d.ts.map +0 -1
- package/lib/types/favicon.d.ts.map +0 -1
- package/lib/types/font.d.ts.map +0 -1
- package/lib/types/i18n-routing.d.ts.map +0 -1
- package/lib/types/image-plugin.d.ts.map +0 -1
- package/lib/types/image.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/link.d.ts.map +0 -1
- package/lib/types/logger.d.ts.map +0 -1
- package/lib/types/meta.d.ts.map +0 -1
- package/lib/types/middleware.d.ts.map +0 -1
- package/lib/types/og-image.d.ts.map +0 -1
- package/lib/types/rate-limit.d.ts.map +0 -1
- package/lib/types/script.d.ts.map +0 -1
- package/lib/types/seo.d.ts.map +0 -1
- package/lib/types/server.d.ts.map +0 -1
- package/lib/types/testing.d.ts.map +0 -1
- package/lib/types/theme.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/zero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Pyreon Zero — zero-config full-stack framework powered by Pyreon and Vite",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Vit Bokisch",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"lib",
|
|
14
|
+
"!lib/**/*.map",
|
|
14
15
|
"!lib/analysis",
|
|
15
16
|
"src",
|
|
16
17
|
"!src/tests",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
21
23
|
"main": "./lib/index.js",
|
|
22
24
|
"module": "./lib/index.js",
|
|
23
25
|
"types": "./lib/types/index.d.ts",
|
|
@@ -166,26 +168,26 @@
|
|
|
166
168
|
"lint": "oxlint ."
|
|
167
169
|
},
|
|
168
170
|
"dependencies": {
|
|
169
|
-
"@pyreon/core": "^0.
|
|
170
|
-
"@pyreon/head": "^0.
|
|
171
|
-
"@pyreon/meta": "^0.
|
|
172
|
-
"@pyreon/router": "^0.
|
|
173
|
-
"@pyreon/runtime-dom": "^0.
|
|
174
|
-
"@pyreon/runtime-server": "^0.
|
|
175
|
-
"@pyreon/server": "^0.
|
|
176
|
-
"@pyreon/vite-plugin": "^0.
|
|
171
|
+
"@pyreon/core": "^0.15.0",
|
|
172
|
+
"@pyreon/head": "^0.15.0",
|
|
173
|
+
"@pyreon/meta": "^0.15.0",
|
|
174
|
+
"@pyreon/router": "^0.15.0",
|
|
175
|
+
"@pyreon/runtime-dom": "^0.15.0",
|
|
176
|
+
"@pyreon/runtime-server": "^0.15.0",
|
|
177
|
+
"@pyreon/server": "^0.15.0",
|
|
178
|
+
"@pyreon/vite-plugin": "^0.15.0",
|
|
177
179
|
"vite": "^8.0.0"
|
|
178
180
|
},
|
|
181
|
+
"devDependencies": {
|
|
182
|
+
"sharp": "^0.33.0"
|
|
183
|
+
},
|
|
179
184
|
"peerDependencies": {
|
|
180
|
-
"@pyreon/reactivity": "^0.
|
|
185
|
+
"@pyreon/reactivity": "^0.15.0",
|
|
181
186
|
"sharp": "^0.33.0"
|
|
182
187
|
},
|
|
183
188
|
"peerDependenciesMeta": {
|
|
184
189
|
"sharp": {
|
|
185
190
|
"optional": true
|
|
186
191
|
}
|
|
187
|
-
},
|
|
188
|
-
"devDependencies": {
|
|
189
|
-
"sharp": "^0.33.0"
|
|
190
192
|
}
|
|
191
193
|
}
|
package/src/app.ts
CHANGED
|
@@ -36,7 +36,27 @@ export function createApp(options: CreateAppOptions) {
|
|
|
36
36
|
scrollBehavior: 'top',
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// Detect the "double layout" footgun. fs-router emits `_layout.tsx` as a
|
|
40
|
+
// parent route record (the canonical Pyreon way to register a layout via
|
|
41
|
+
// file-system routing). If the user ALSO passes `options.layout` referring
|
|
42
|
+
// to the same component, the layout mounts twice — once via App's wrapper
|
|
43
|
+
// and once via the matched route chain. Result on hydration mismatch:
|
|
44
|
+
// 3× `nav.sidebar` + 3× `main.content`.
|
|
45
|
+
//
|
|
46
|
+
// Defense: when `options.layout` references the same component as ANY
|
|
47
|
+
// top-level route's `component`, drop the explicit option (the route-chain
|
|
48
|
+
// path is canonical) and warn in dev. Anyone who genuinely wants two
|
|
49
|
+
// layout wrappers can compose them inside a single component themselves.
|
|
50
|
+
const hasLayoutInRoutes =
|
|
51
|
+
options.layout !== undefined &&
|
|
52
|
+
options.routes.some((r) => r.component === options.layout)
|
|
53
|
+
if (hasLayoutInRoutes && process.env.NODE_ENV !== 'production') {
|
|
54
|
+
// oxlint-disable-next-line no-console
|
|
55
|
+
console.warn(
|
|
56
|
+
'[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
const Layout = hasLayoutInRoutes ? DefaultLayout : (options.layout ?? DefaultLayout)
|
|
40
60
|
|
|
41
61
|
function App() {
|
|
42
62
|
return h(
|
package/src/csp.ts
CHANGED
|
@@ -140,21 +140,37 @@ export function buildCspHeader(directives: CspDirectives, nonce?: string): strin
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* Generate a random nonce string (base64, 16 bytes).
|
|
143
|
+
* Generate a cryptographically-random nonce string (base64, 16 bytes).
|
|
144
|
+
*
|
|
145
|
+
* Throws when `crypto.getRandomValues` is unavailable. CSP nonces protect
|
|
146
|
+
* against XSS by gating inline script execution; a predictable nonce
|
|
147
|
+
* (`Math.random` ~31 bits of entropy) bypasses CSP entirely. Silent
|
|
148
|
+
* degradation here was a security anti-pattern — we surface the
|
|
149
|
+
* misconfiguration loudly instead.
|
|
150
|
+
*
|
|
151
|
+
* Realistic deployments always have `crypto.getRandomValues`: Node 18+,
|
|
152
|
+
* Bun, Deno, browsers, edge workers (Cloudflare/Vercel/Netlify), and
|
|
153
|
+
* vitest/happy-dom all expose it via `globalThis.crypto`. If you hit
|
|
154
|
+
* this throw, your environment is unusual — fix the env, don't downgrade
|
|
155
|
+
* the security primitive.
|
|
144
156
|
*/
|
|
145
157
|
function generateNonce(): string {
|
|
146
|
-
if (typeof crypto
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
? btoa(binary)
|
|
154
|
-
: Buffer.from(bytes).toString('base64')
|
|
158
|
+
if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
'[Pyreon] CSP nonce generation requires `crypto.getRandomValues` (Web Crypto API). ' +
|
|
161
|
+
'No secure RNG is available in this environment. CSP nonces must be cryptographically ' +
|
|
162
|
+
'random — falling back to `Math.random` would silently weaken XSS protection. ' +
|
|
163
|
+
'Ensure Node 18+, Bun, Deno, an edge runtime, or a browser environment.',
|
|
164
|
+
)
|
|
155
165
|
}
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
const bytes = new Uint8Array(16)
|
|
167
|
+
crypto.getRandomValues(bytes)
|
|
168
|
+
// Convert to base64 using btoa
|
|
169
|
+
let binary = ''
|
|
170
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
171
|
+
return typeof btoa === 'function'
|
|
172
|
+
? btoa(binary)
|
|
173
|
+
: Buffer.from(bytes).toString('base64')
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
/**
|
package/src/fs-router.ts
CHANGED
|
@@ -40,6 +40,8 @@ const ROUTE_EXPORT_NAMES = [
|
|
|
40
40
|
'renderMode',
|
|
41
41
|
'error',
|
|
42
42
|
'middleware',
|
|
43
|
+
'loaderKey',
|
|
44
|
+
'gcTime',
|
|
43
45
|
] as const
|
|
44
46
|
|
|
45
47
|
type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
|
|
@@ -114,6 +116,8 @@ export function detectRouteExports(source: string): RouteFileExports {
|
|
|
114
116
|
hasRenderMode: found.has('renderMode'),
|
|
115
117
|
hasError: found.has('error'),
|
|
116
118
|
hasMiddleware: found.has('middleware'),
|
|
119
|
+
hasLoaderKey: found.has('loaderKey'),
|
|
120
|
+
hasGcTime: found.has('gcTime'),
|
|
117
121
|
...(metaLiteral !== undefined ? { metaLiteral } : {}),
|
|
118
122
|
...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
|
|
119
123
|
}
|
|
@@ -745,6 +749,8 @@ const EMPTY_EXPORTS: RouteFileExports = {
|
|
|
745
749
|
hasRenderMode: false,
|
|
746
750
|
hasError: false,
|
|
747
751
|
hasMiddleware: false,
|
|
752
|
+
hasLoaderKey: false,
|
|
753
|
+
hasGcTime: false,
|
|
748
754
|
}
|
|
749
755
|
|
|
750
756
|
/**
|
|
@@ -759,7 +765,9 @@ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
|
|
|
759
765
|
exports.hasMeta ||
|
|
760
766
|
exports.hasRenderMode ||
|
|
761
767
|
exports.hasError ||
|
|
762
|
-
exports.hasMiddleware
|
|
768
|
+
exports.hasMiddleware ||
|
|
769
|
+
exports.hasLoaderKey ||
|
|
770
|
+
exports.hasGcTime
|
|
763
771
|
)
|
|
764
772
|
}
|
|
765
773
|
|
|
@@ -1085,6 +1093,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1085
1093
|
props.push(`${indent} component: ${mod}.default`)
|
|
1086
1094
|
if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
|
|
1087
1095
|
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1096
|
+
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1097
|
+
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1088
1098
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1089
1099
|
const metaParts: string[] = []
|
|
1090
1100
|
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
@@ -1152,6 +1162,18 @@ export function generateRouteModuleFromRoutes(
|
|
|
1152
1162
|
`${indent} beforeEnter: (to, from) => import("${fullPath}").then((m) => m.guard(to, from))`,
|
|
1153
1163
|
)
|
|
1154
1164
|
}
|
|
1165
|
+
if (exp.hasLoaderKey) {
|
|
1166
|
+
// loaderKey runs SYNCHRONOUSLY during the cache-key check; can't be
|
|
1167
|
+
// routed through a dynamic import. Inline a `mod.loaderKey` lookup
|
|
1168
|
+
// via the same namespace-import pattern as the metadata path. Rolldown
|
|
1169
|
+
// will share the chunk with the lazy() component thunk.
|
|
1170
|
+
const mod = nextModuleImport(page.filePath)
|
|
1171
|
+
props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1172
|
+
}
|
|
1173
|
+
if (exp.hasGcTime) {
|
|
1174
|
+
const mod = nextModuleImport(page.filePath)
|
|
1175
|
+
props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1176
|
+
}
|
|
1155
1177
|
emitInlineMeta(exp, props, indent)
|
|
1156
1178
|
if (errorName) {
|
|
1157
1179
|
// For error components we can't easily await — pass the lazy
|
|
@@ -1171,6 +1193,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1171
1193
|
props.push(`${indent} component: ${mod}.default`)
|
|
1172
1194
|
if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
|
|
1173
1195
|
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1196
|
+
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1197
|
+
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1174
1198
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1175
1199
|
const metaParts: string[] = []
|
|
1176
1200
|
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
@@ -1231,6 +1255,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1231
1255
|
if (layoutMod !== undefined) {
|
|
1232
1256
|
if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)
|
|
1233
1257
|
if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)
|
|
1258
|
+
if (exp.hasLoaderKey) props.push(`${indent}loaderKey: ${layoutMod}.loaderKey`)
|
|
1259
|
+
if (exp.hasGcTime) props.push(`${indent}gcTime: ${layoutMod}.gcTime`)
|
|
1234
1260
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1235
1261
|
const metaParts: string[] = []
|
|
1236
1262
|
if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)
|
|
@@ -1307,8 +1333,15 @@ export function generateRouteModuleFromRoutes(
|
|
|
1307
1333
|
/**
|
|
1308
1334
|
* Generate a virtual module that maps URL patterns to their middleware exports.
|
|
1309
1335
|
* Used by the server entry to dispatch per-route middleware.
|
|
1336
|
+
*
|
|
1337
|
+
* Detects whether each route file actually exports `middleware` (via
|
|
1338
|
+
* `detectRouteExports` source scanning) and only emits an import for files
|
|
1339
|
+
* that do. The `lazy()` import path tolerates missing exports, but the SSG
|
|
1340
|
+
* static-import path fails Rolldown's missing-export check at build time —
|
|
1341
|
+
* skipping no-middleware files keeps both paths working.
|
|
1310
1342
|
*/
|
|
1311
1343
|
export function generateMiddlewareModule(files: string[], routesDir: string): string {
|
|
1344
|
+
const { readFileSync } = require('node:fs') as typeof import('node:fs')
|
|
1312
1345
|
const routes = parseFileRoutes(files)
|
|
1313
1346
|
const imports: string[] = []
|
|
1314
1347
|
const entries: string[] = []
|
|
@@ -1316,6 +1349,14 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
|
|
|
1316
1349
|
|
|
1317
1350
|
for (const route of routes) {
|
|
1318
1351
|
if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
|
|
1352
|
+
let hasMw = false
|
|
1353
|
+
try {
|
|
1354
|
+
const source = readFileSync(`${routesDir}/${route.filePath}`, 'utf-8')
|
|
1355
|
+
hasMw = detectRouteExports(source).hasMiddleware
|
|
1356
|
+
} catch {
|
|
1357
|
+
// File can't be read — skip; the SSR runtime falls back gracefully.
|
|
1358
|
+
}
|
|
1359
|
+
if (!hasMw) continue
|
|
1319
1360
|
const name = `_mw${counter++}`
|
|
1320
1361
|
const fullPath = `${routesDir}/${route.filePath}`
|
|
1321
1362
|
imports.push(`import { middleware as ${name} } from "${fullPath}"`)
|
|
@@ -1372,8 +1413,17 @@ export async function scanRouteFilesWithExports(
|
|
|
1372
1413
|
defaultMode: RenderMode = 'ssr',
|
|
1373
1414
|
): Promise<FileRoute[]> {
|
|
1374
1415
|
const { readFile } = await import('node:fs/promises')
|
|
1375
|
-
|
|
1376
|
-
|
|
1416
|
+
const { isApiRoute } = await import('./api-routes')
|
|
1417
|
+
|
|
1418
|
+
// Api routes (`api/**/*.ts`) live in the same routes tree but are served by
|
|
1419
|
+
// a separate virtual module (`virtual:zero/api-routes`). Page-route
|
|
1420
|
+
// generation MUST skip them — they export named HTTP method handlers
|
|
1421
|
+
// (`GET`/`POST`/...), not a default page component, so the SSG `staticImports`
|
|
1422
|
+
// mode would emit `import _N from "api/posts.ts"` and fail Rolldown's
|
|
1423
|
+
// missing-export check at build time. The bug only surfaced under SSG
|
|
1424
|
+
// because the regular lazy()-mode `import()` doesn't fail on missing
|
|
1425
|
+
// default exports.
|
|
1426
|
+
const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f))
|
|
1377
1427
|
const exportsMap = new Map<string, RouteFileExports>()
|
|
1378
1428
|
|
|
1379
1429
|
await Promise.all(
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSG (Static Site Generation) build hook for `@pyreon/zero`.
|
|
3
|
+
*
|
|
4
|
+
* Activates when `mode: "ssg"` is set in zero's config. After Vite's client
|
|
5
|
+
* build finishes, this plugin:
|
|
6
|
+
*
|
|
7
|
+
* 1. Triggers a programmatic SSR build via Vite's `build()` API, producing
|
|
8
|
+
* a server bundle in `dist/.zero-ssg-server/` from a synthetic entry
|
|
9
|
+
* that imports `virtual:zero/routes` and `createServer`.
|
|
10
|
+
* 2. Loads the built handler with dynamic `import()`.
|
|
11
|
+
* 3. Resolves the path list from `config.ssg.paths` (string[], async fn,
|
|
12
|
+
* or auto-detected from the static-only routes in the route tree).
|
|
13
|
+
* 4. Calls `prerender()` from `@pyreon/server` to render each path.
|
|
14
|
+
* 5. Cleans up the temporary SSR build directory.
|
|
15
|
+
*
|
|
16
|
+
* Before this PR, `mode: "ssg"` and `ssg.paths` were typed in
|
|
17
|
+
* `types.ts` but had no runtime implementation — the plugin file had zero
|
|
18
|
+
* Rollup build hooks. Apps configured for SSG silently shipped a bare SPA
|
|
19
|
+
* shell with no per-route HTML files, which broke direct-URL deploys to
|
|
20
|
+
* static hosts (no `dist/<path>/index.html`, every URL falls back to the
|
|
21
|
+
* SPA index).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { dirname, join, resolve } from 'node:path'
|
|
27
|
+
import { pathToFileURL } from 'node:url'
|
|
28
|
+
import type { Plugin } from 'vite'
|
|
29
|
+
import { resolveConfig } from './config'
|
|
30
|
+
import { parseFileRoutes, scanRouteFiles } from './fs-router'
|
|
31
|
+
import type { ZeroConfig } from './types'
|
|
32
|
+
|
|
33
|
+
// Marker env var used to skip the SSG hook on the recursive SSR sub-build —
|
|
34
|
+
// the SSR pass loads the same vite config + same plugin chain, so without
|
|
35
|
+
// this guard the SSG hook would re-trigger an infinite build loop.
|
|
36
|
+
const SSG_BUILD_FLAG = 'PYREON_ZERO_SSG_INNER_BUILD'
|
|
37
|
+
|
|
38
|
+
// Synthetic SSR entry source. Imports the user's route tree via the virtual
|
|
39
|
+
// module that zero's main plugin already registers, then exports a default
|
|
40
|
+
// `(path: string) => Promise<string>` renderer that returns the full HTML
|
|
41
|
+
// for a single path.
|
|
42
|
+
//
|
|
43
|
+
// The entry is materialized to disk (not registered as a virtual module)
|
|
44
|
+
// because Rolldown's `rollupOptions.input` phase doesn't reliably resolve
|
|
45
|
+
// `\0`-prefixed virtual ids when used as build entries — a virtual id
|
|
46
|
+
// returned from `resolveId` works fine for downstream imports but fails
|
|
47
|
+
// the entry-resolution stage with `Cannot resolve entry module`.
|
|
48
|
+
//
|
|
49
|
+
// We do NOT use zero's `createServer` because it wraps the user's App with
|
|
50
|
+
// a router whose URL is baked in at App-creation time. SSG needs a fresh
|
|
51
|
+
// router per path, so we mirror the dev SSR pipeline (`renderSsr` in
|
|
52
|
+
// vite-plugin.ts): per request → new createApp({ url: path }) → preload
|
|
53
|
+
// loaders → renderWithHead → serialize loader data → done.
|
|
54
|
+
const SSR_ENTRY_SOURCE = `
|
|
55
|
+
import { routes } from "virtual:zero/routes"
|
|
56
|
+
import { h } from "@pyreon/core"
|
|
57
|
+
import { renderWithHead } from "@pyreon/head/ssr"
|
|
58
|
+
import { serializeLoaderData } from "@pyreon/router"
|
|
59
|
+
import { runWithRequestContext } from "@pyreon/runtime-server"
|
|
60
|
+
import { createApp } from "@pyreon/zero/server"
|
|
61
|
+
|
|
62
|
+
export default async function renderPath(path) {
|
|
63
|
+
const { App, router } = createApp({
|
|
64
|
+
routes,
|
|
65
|
+
routerMode: "history",
|
|
66
|
+
url: path,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
await router.preload(path)
|
|
70
|
+
|
|
71
|
+
return runWithRequestContext(async () => {
|
|
72
|
+
const app = h(App, null)
|
|
73
|
+
const { html: appHtml, head } = await renderWithHead(app)
|
|
74
|
+
const loaderData = serializeLoaderData(router)
|
|
75
|
+
const hasData = loaderData && Object.keys(loaderData).length > 0
|
|
76
|
+
const loaderScript = hasData
|
|
77
|
+
? \`<script>window.__PYREON_LOADER_DATA__=\${JSON.stringify(loaderData).replace(/<\\//g, "<\\\\/")}</script>\`
|
|
78
|
+
: ""
|
|
79
|
+
return { appHtml, head, loaderScript }
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
`.trimStart()
|
|
83
|
+
|
|
84
|
+
const SSR_ENTRY_FILENAME = '__pyreon-zero-ssg-entry.js'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Auto-detect static paths from the route tree. A "static" path is one with
|
|
88
|
+
* NO dynamic segments (`[id]`, `[...rest]`). Dynamic routes are skipped
|
|
89
|
+
* because we can't enumerate their values at build time without a
|
|
90
|
+
* `getStaticPaths`-style API.
|
|
91
|
+
*/
|
|
92
|
+
async function autoDetectStaticPaths(routesDir: string): Promise<string[]> {
|
|
93
|
+
// Routes dir missing → fall back to "/" anyway. A project that doesn't
|
|
94
|
+
// expose routes via fs-routing (custom routes module, single-page app
|
|
95
|
+
// shell, etc.) still needs at least an index.html so static hosts have
|
|
96
|
+
// a default response. The user can always set explicit `ssg.paths` to
|
|
97
|
+
// override this floor.
|
|
98
|
+
if (!existsSync(routesDir)) return ['/']
|
|
99
|
+
const files = await scanRouteFiles(routesDir)
|
|
100
|
+
const fileRoutes = parseFileRoutes(files)
|
|
101
|
+
|
|
102
|
+
// FileRoute is a FLAT list (no nested children) keyed by `urlPath`.
|
|
103
|
+
// Dynamic segments compile to `:param` (e.g. `[id]` → `:id`) and
|
|
104
|
+
// catch-alls to `*`. Skip any urlPath containing those — they need a
|
|
105
|
+
// `getStaticPaths`-style API to enumerate concrete values, which Pyreon
|
|
106
|
+
// doesn't ship yet.
|
|
107
|
+
const out: string[] = []
|
|
108
|
+
for (const r of fileRoutes) {
|
|
109
|
+
if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue
|
|
110
|
+
const path = r.urlPath
|
|
111
|
+
if (!path) continue
|
|
112
|
+
if (/[:*]/.test(path)) continue
|
|
113
|
+
out.push(path)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Always include "/" as a fallback if no static routes were found —
|
|
117
|
+
// a project with only dynamic routes still needs an index.html for the
|
|
118
|
+
// host to know where to send unmatched URLs.
|
|
119
|
+
return out.length > 0 ? out : ['/']
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resolvePaths(
|
|
123
|
+
config: ZeroConfig,
|
|
124
|
+
routesDir: string,
|
|
125
|
+
): Promise<string[]> {
|
|
126
|
+
const explicit = config.ssg?.paths
|
|
127
|
+
if (typeof explicit === 'function') {
|
|
128
|
+
const result = await explicit()
|
|
129
|
+
return Array.isArray(result) ? result : []
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(explicit)) return explicit
|
|
132
|
+
return autoDetectStaticPaths(routesDir)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveOutputPath(distDir: string, path: string): string {
|
|
136
|
+
if (path === '/') return join(distDir, 'index.html')
|
|
137
|
+
if (path.endsWith('.html')) return join(distDir, path)
|
|
138
|
+
return join(distDir, path, 'index.html')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
|
|
143
|
+
* Vite's `closeBundle` hook so it runs once after the main client build
|
|
144
|
+
* completes. The recursive SSR sub-build is gated by an env flag.
|
|
145
|
+
*/
|
|
146
|
+
export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
147
|
+
const config = resolveConfig(userConfig)
|
|
148
|
+
let root = ''
|
|
149
|
+
let distDir = ''
|
|
150
|
+
// Track whether this plugin instance is running inside the inner SSR
|
|
151
|
+
// sub-build (where it must be a no-op) vs. the outer client build.
|
|
152
|
+
const isInnerBuild = process.env[SSG_BUILD_FLAG] === '1'
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
name: 'pyreon-zero-ssg',
|
|
156
|
+
apply: 'build',
|
|
157
|
+
enforce: 'post',
|
|
158
|
+
|
|
159
|
+
configResolved(resolved) {
|
|
160
|
+
root = resolved.root
|
|
161
|
+
distDir = resolve(root, resolved.build.outDir)
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async closeBundle() {
|
|
165
|
+
if (config.mode !== 'ssg') return
|
|
166
|
+
if (isInnerBuild) return
|
|
167
|
+
|
|
168
|
+
const ssrOutDir = join(distDir, '.zero-ssg-server')
|
|
169
|
+
const indexHtmlPath = join(distDir, 'index.html')
|
|
170
|
+
|
|
171
|
+
if (!existsSync(indexHtmlPath)) {
|
|
172
|
+
// Client build hasn't produced index.html — nothing we can wrap.
|
|
173
|
+
// Most likely: user is running `vite build --ssr` directly, in
|
|
174
|
+
// which case this plugin shouldn't be active anyway.
|
|
175
|
+
// oxlint-disable-next-line no-console
|
|
176
|
+
console.warn(
|
|
177
|
+
`[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`,
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Materialize the SSR entry to disk inside the routes directory so
|
|
183
|
+
// its imports resolve relative to the user's source tree. Doing this
|
|
184
|
+
// INSIDE node_modules-equivalent paths breaks Vite's plugin-resolution
|
|
185
|
+
// semantics; placing it next to the user's routes lets zero's main
|
|
186
|
+
// plugin pick it up identically to user code. Cleaned up after the
|
|
187
|
+
// build.
|
|
188
|
+
const entryPath = join(root, SSR_ENTRY_FILENAME)
|
|
189
|
+
await writeFile(entryPath, SSR_ENTRY_SOURCE, 'utf-8')
|
|
190
|
+
|
|
191
|
+
// Vite's programmatic build API. Loaded lazily so the plugin doesn't
|
|
192
|
+
// pull `vite` into the runtime dep graph at module-evaluation time.
|
|
193
|
+
const { build } = await import('vite')
|
|
194
|
+
|
|
195
|
+
// Inner SSR sub-build. Re-assembles zero's plugin chain plus
|
|
196
|
+
// `@pyreon/vite-plugin` (JSX compiler) — every Pyreon app already
|
|
197
|
+
// has both because zero is built on top of pyreon. Loading both
|
|
198
|
+
// lazily keeps the SSG plugin off the module-eval critical path.
|
|
199
|
+
// Env-flag gate prevents the inner ssgPlugin instance from
|
|
200
|
+
// re-triggering itself.
|
|
201
|
+
process.env[SSG_BUILD_FLAG] = '1'
|
|
202
|
+
try {
|
|
203
|
+
const [{ zeroPlugin }, pyreonModule] = await Promise.all([
|
|
204
|
+
import('./vite-plugin'),
|
|
205
|
+
import('@pyreon/vite-plugin'),
|
|
206
|
+
])
|
|
207
|
+
const pyreon = (pyreonModule as { default: () => unknown }).default
|
|
208
|
+
|
|
209
|
+
await build({
|
|
210
|
+
root,
|
|
211
|
+
mode: 'production',
|
|
212
|
+
logLevel: 'error',
|
|
213
|
+
configFile: false,
|
|
214
|
+
publicDir: false,
|
|
215
|
+
plugins: [pyreon(), zeroPlugin(userConfig)] as Plugin[],
|
|
216
|
+
resolve: { conditions: ['bun'] },
|
|
217
|
+
build: {
|
|
218
|
+
ssr: entryPath,
|
|
219
|
+
outDir: ssrOutDir,
|
|
220
|
+
emptyOutDir: true,
|
|
221
|
+
target: 'esnext',
|
|
222
|
+
rollupOptions: {
|
|
223
|
+
input: entryPath,
|
|
224
|
+
output: {
|
|
225
|
+
format: 'es',
|
|
226
|
+
entryFileNames: 'entry-server.mjs',
|
|
227
|
+
},
|
|
228
|
+
external: [/^node:/],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
} finally {
|
|
233
|
+
delete process.env[SSG_BUILD_FLAG]
|
|
234
|
+
// Remove the synthetic entry file so it never lands in user's
|
|
235
|
+
// working tree.
|
|
236
|
+
try {
|
|
237
|
+
await rm(entryPath, { force: true })
|
|
238
|
+
} catch {
|
|
239
|
+
// best-effort cleanup
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Load the built renderer. Use a file:// URL to avoid Node import
|
|
244
|
+
// cache collisions across multiple builds within the same process.
|
|
245
|
+
const handlerPath = join(ssrOutDir, 'entry-server.mjs')
|
|
246
|
+
if (!existsSync(handlerPath)) {
|
|
247
|
+
// oxlint-disable-next-line no-console
|
|
248
|
+
console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
// The path is computed at runtime from a freshly-built SSR artifact
|
|
252
|
+
// — Vite's `dynamic-import-vars` plugin can't statically analyze the
|
|
253
|
+
// import. Without the `@vite-ignore` hint, Vite emits a console
|
|
254
|
+
// warning on every consumer's dev server boot ("The above dynamic
|
|
255
|
+
// import cannot be analyzed by Vite"), which looks alarming but is
|
|
256
|
+
// expected here. Suppress per Vite's own recommendation.
|
|
257
|
+
const handlerMod = (await import(/* @vite-ignore */ pathToFileURL(handlerPath).href)) as {
|
|
258
|
+
default: (path: string) => Promise<{ appHtml: string; head: string; loaderScript: string }>
|
|
259
|
+
}
|
|
260
|
+
const renderPath = handlerMod.default
|
|
261
|
+
|
|
262
|
+
// Read the user's built index.html template. Vite has just produced it
|
|
263
|
+
// with hashed asset URLs (`/assets/index-XYZ.js`), preload links, etc.
|
|
264
|
+
// We inject the rendered head/body/loader-data into placeholder
|
|
265
|
+
// comments — same convention as zero's dev SSR. If the template lacks
|
|
266
|
+
// the placeholders, we fall back to inserting before `</head>` and
|
|
267
|
+
// `</body>` respectively so a bare `index.html` still works.
|
|
268
|
+
const template = await readFile(indexHtmlPath, 'utf-8')
|
|
269
|
+
|
|
270
|
+
// Resolve paths and render.
|
|
271
|
+
const routesDir = join(root, 'src', 'routes')
|
|
272
|
+
const paths = await resolvePaths(config, routesDir)
|
|
273
|
+
|
|
274
|
+
if (paths.length === 0) {
|
|
275
|
+
// oxlint-disable-next-line no-console
|
|
276
|
+
console.warn('[zero:ssg] No static paths to prerender — set ssg.paths in zero config')
|
|
277
|
+
await rm(ssrOutDir, { recursive: true, force: true })
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let pages = 0
|
|
282
|
+
const errors: { path: string; error: unknown }[] = []
|
|
283
|
+
const start = Date.now()
|
|
284
|
+
|
|
285
|
+
for (const p of paths) {
|
|
286
|
+
try {
|
|
287
|
+
const result = await Promise.race([
|
|
288
|
+
renderPath(p),
|
|
289
|
+
new Promise<never>((_, reject) =>
|
|
290
|
+
setTimeout(() => reject(new Error(`Prerender timeout for "${p}" (30s)`)), 30_000),
|
|
291
|
+
),
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
// Inject into the index.html template. Prefer Pyreon's standard
|
|
295
|
+
// placeholders; fall back to <head>/<body>/<#app> insertion
|
|
296
|
+
// points so apps with a minimal `<div id="app"></div>` template
|
|
297
|
+
// still render content.
|
|
298
|
+
let html = template
|
|
299
|
+
if (html.includes('<!--pyreon-head-->')) {
|
|
300
|
+
html = html.replace('<!--pyreon-head-->', result.head)
|
|
301
|
+
} else if (result.head) {
|
|
302
|
+
html = html.replace('</head>', `${result.head}</head>`)
|
|
303
|
+
}
|
|
304
|
+
if (html.includes('<!--pyreon-app-->')) {
|
|
305
|
+
html = html.replace('<!--pyreon-app-->', result.appHtml)
|
|
306
|
+
} else if (result.appHtml) {
|
|
307
|
+
// Drop the rendered HTML inside #app; if not found, append to body.
|
|
308
|
+
const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/)
|
|
309
|
+
if (appDivMatch) {
|
|
310
|
+
html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`)
|
|
311
|
+
} else {
|
|
312
|
+
html = html.replace('</body>', `<div id="app">${result.appHtml}</div></body>`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (html.includes('<!--pyreon-scripts-->')) {
|
|
316
|
+
html = html.replace('<!--pyreon-scripts-->', result.loaderScript)
|
|
317
|
+
} else if (result.loaderScript) {
|
|
318
|
+
html = html.replace('</body>', `${result.loaderScript}</body>`)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const filePath = resolveOutputPath(distDir, p)
|
|
322
|
+
|
|
323
|
+
// Path-traversal guard — same as @pyreon/server's prerender.
|
|
324
|
+
const resolvedOut = resolve(distDir)
|
|
325
|
+
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
326
|
+
errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
331
|
+
await writeFile(filePath, html, 'utf-8')
|
|
332
|
+
pages++
|
|
333
|
+
} catch (error) {
|
|
334
|
+
errors.push({ path: p, error })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Cleanup the SSR build artifacts — they're an implementation detail
|
|
339
|
+
// and shouldn't ship to the static host.
|
|
340
|
+
await rm(ssrOutDir, { recursive: true, force: true })
|
|
341
|
+
|
|
342
|
+
const elapsed = Date.now() - start
|
|
343
|
+
// oxlint-disable-next-line no-console
|
|
344
|
+
console.log(
|
|
345
|
+
`[zero:ssg] Prerendered ${pages} page(s) in ${elapsed}ms` +
|
|
346
|
+
(errors.length > 0 ? ` (${errors.length} error(s))` : ''),
|
|
347
|
+
)
|
|
348
|
+
for (const { path: errPath, error } of errors) {
|
|
349
|
+
// oxlint-disable-next-line no-console
|
|
350
|
+
console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error)
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
} satisfies Plugin
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Test exports ─────────────────────────────────────────────────────────────
|
|
357
|
+
//
|
|
358
|
+
// Internal helpers exposed for unit tests. Not part of the public API.
|
|
359
|
+
|
|
360
|
+
export const _internal = {
|
|
361
|
+
resolvePaths,
|
|
362
|
+
autoDetectStaticPaths,
|
|
363
|
+
resolveOutputPath,
|
|
364
|
+
SSR_ENTRY_SOURCE,
|
|
365
|
+
SSR_ENTRY_FILENAME,
|
|
366
|
+
}
|