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