@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/src/types.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { ComponentFn } from '@pyreon/core'
|
|
2
|
-
import type { NavigationGuard } from '@pyreon/router'
|
|
2
|
+
import type { LoaderContext, NavigationGuard } from '@pyreon/router'
|
|
3
3
|
import type { Middleware } from '@pyreon/server'
|
|
4
4
|
|
|
5
|
+
// Re-export router's `LoaderContext` so consumers importing it from
|
|
6
|
+
// `@pyreon/zero` keep working. The previous duplicate `interface
|
|
7
|
+
// LoaderContext` (with a `request: Request` field that was never
|
|
8
|
+
// populated by the actually-constructed runtime ctx) was a
|
|
9
|
+
// typed-but-unimplemented bug class — caught by `audit-types`. If
|
|
10
|
+
// SSR loaders need access to the request, plumb it through the
|
|
11
|
+
// router-level `LoaderContext` in a follow-up PR; do NOT add fields
|
|
12
|
+
// here that the runtime doesn't populate.
|
|
13
|
+
export type { LoaderContext }
|
|
14
|
+
|
|
5
15
|
// ─── Route module conventions ────────────────────────────────────────────────
|
|
6
16
|
|
|
7
17
|
/** What a route file (e.g. `src/routes/index.tsx`) can export. */
|
|
@@ -26,14 +36,6 @@ export interface RouteModule {
|
|
|
26
36
|
renderMode?: RenderMode
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
/** Context passed to route loaders. */
|
|
30
|
-
export interface LoaderContext {
|
|
31
|
-
params: Record<string, string>
|
|
32
|
-
query: Record<string, string>
|
|
33
|
-
signal: AbortSignal
|
|
34
|
-
request: Request
|
|
35
|
-
}
|
|
36
|
-
|
|
37
39
|
/** Per-route metadata. */
|
|
38
40
|
export interface RouteMeta {
|
|
39
41
|
title?: string
|
|
@@ -116,6 +118,23 @@ export interface RouteFileExports {
|
|
|
116
118
|
hasError: boolean
|
|
117
119
|
/** Has `export const middleware` */
|
|
118
120
|
hasMiddleware: boolean
|
|
121
|
+
/**
|
|
122
|
+
* Has `export const loaderKey` or `export function loaderKey`. When present,
|
|
123
|
+
* the route generator wires it as the `loaderKey` field on the route record,
|
|
124
|
+
* which controls cache identity for `_loaderCache`. Useful for auth-gate
|
|
125
|
+
* loaders that should invalidate when the session cookie changes — read
|
|
126
|
+
* `document.cookie` (CSR) or `ctx.request.headers.get('cookie')` (SSR) and
|
|
127
|
+
* derive a key from session identity. Default cache key is `path + params`,
|
|
128
|
+
* which doesn't see cookie changes.
|
|
129
|
+
*/
|
|
130
|
+
hasLoaderKey: boolean
|
|
131
|
+
/**
|
|
132
|
+
* Has `export const gcTime` (number, in ms). When present, the route generator
|
|
133
|
+
* inlines it on the route record. `gcTime: 0` disables caching entirely —
|
|
134
|
+
* the loader runs on every navigation. Useful for auth-gate loaders that
|
|
135
|
+
* must validate session on every navigation rather than serve stale data.
|
|
136
|
+
*/
|
|
137
|
+
hasGcTime: boolean
|
|
119
138
|
/**
|
|
120
139
|
* Raw text of the `export const meta = …` initializer, captured as a
|
|
121
140
|
* literal expression. When present, the route generator inlines this
|
package/src/vite-plugin.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { existsSync, readdirSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { Readable } from 'node:stream'
|
|
4
5
|
import type { Plugin, ViteDevServer } from 'vite'
|
|
5
|
-
import {
|
|
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'
|
|
6
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'
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
|
|
@@ -45,6 +55,7 @@ import {
|
|
|
45
55
|
scanRouteFilesWithExports,
|
|
46
56
|
} from "./fs-router";
|
|
47
57
|
import { render404Page } from "./not-found";
|
|
58
|
+
import { ssgPlugin } from "./ssg-plugin";
|
|
48
59
|
import type { ZeroConfig } from "./types";
|
|
49
60
|
|
|
50
61
|
const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
|
|
@@ -69,12 +80,12 @@ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
|
|
|
69
80
|
* plugins: [pyreon(), zero()],
|
|
70
81
|
* }
|
|
71
82
|
*/
|
|
72
|
-
export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
83
|
+
export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
|
|
73
84
|
const config = resolveConfig(userConfig);
|
|
74
85
|
let routesDir: string;
|
|
75
86
|
let root: string;
|
|
76
87
|
|
|
77
|
-
const
|
|
88
|
+
const mainPlugin: Plugin & { _zeroConfig: ZeroConfig } = {
|
|
78
89
|
name: "pyreon-zero",
|
|
79
90
|
enforce: "pre",
|
|
80
91
|
_zeroConfig: userConfig,
|
|
@@ -127,6 +138,35 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
127
138
|
},
|
|
128
139
|
|
|
129
140
|
configureServer(server) {
|
|
141
|
+
// Dev-mode API-route middleware — production wires `createApiMiddleware`
|
|
142
|
+
// via `createServer`, but dev had no equivalent. API requests fell
|
|
143
|
+
// through to Vite's default 404. This middleware loads the
|
|
144
|
+
// `virtual:zero/api-routes` module and dispatches matching requests to
|
|
145
|
+
// the route's `GET()` / `POST()` / etc. handler. Mirrors the production
|
|
146
|
+
// flow in `entry-server.ts` minus the ergonomic helpers (auth, cors,
|
|
147
|
+
// etc. — those plug in via user-defined Pyreon middleware which dev
|
|
148
|
+
// doesn't currently load; not in scope here).
|
|
149
|
+
//
|
|
150
|
+
// Registered FIRST so API requests don't get SSR'd or 404'd.
|
|
151
|
+
server.middlewares.use((req, res, next) => {
|
|
152
|
+
const pathname = req.url?.split("?")[0] ?? "/";
|
|
153
|
+
if (pathname.startsWith("/@") || pathname.startsWith("/__"))
|
|
154
|
+
return next();
|
|
155
|
+
// Skip files (extension-bearing) — let Vite's static pipeline serve.
|
|
156
|
+
if (/\.\w+$/.test(pathname)) return next();
|
|
157
|
+
|
|
158
|
+
dispatchApiRoute(server, req, res).then(
|
|
159
|
+
(handled) => {
|
|
160
|
+
if (!handled) next();
|
|
161
|
+
},
|
|
162
|
+
(err: unknown) => {
|
|
163
|
+
// oxlint-disable-next-line no-console
|
|
164
|
+
console.error("[Pyreon] Error in dev API dispatcher:", err);
|
|
165
|
+
next();
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
130
170
|
// Dev-mode SSR middleware — for mode: "ssr", actually render each
|
|
131
171
|
// matched route server-side instead of serving the SPA shell.
|
|
132
172
|
// Runs BEFORE the 404 handler so matched routes are SSR'd and
|
|
@@ -141,7 +181,26 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
141
181
|
return next();
|
|
142
182
|
if (/\.\w+$/.test(pathname)) return next();
|
|
143
183
|
|
|
144
|
-
|
|
184
|
+
// Build a Web Request from the Node IncomingMessage so loaders
|
|
185
|
+
// can read cookies / auth headers via `ctx.request` and call
|
|
186
|
+
// `redirect()` from a server-side context.
|
|
187
|
+
const reqHost = req.headers.host ?? "localhost";
|
|
188
|
+
const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
|
|
189
|
+
const reqHeaders = new Headers();
|
|
190
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
191
|
+
if (value !== undefined) {
|
|
192
|
+
reqHeaders.set(
|
|
193
|
+
key,
|
|
194
|
+
Array.isArray(value) ? value.join(", ") : String(value),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const webReq = new Request(reqUrl.href, {
|
|
199
|
+
method: req.method ?? "GET",
|
|
200
|
+
headers: reqHeaders,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
|
|
145
204
|
(result) => {
|
|
146
205
|
if (result === null) return next();
|
|
147
206
|
res.statusCode = 200;
|
|
@@ -150,6 +209,16 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
150
209
|
res.end(result);
|
|
151
210
|
},
|
|
152
211
|
(err: unknown) => {
|
|
212
|
+
// Loader-thrown `redirect()` — convert to a real HTTP redirect
|
|
213
|
+
// (302/307/308) BEFORE the layout renders. This is the dev-mode
|
|
214
|
+
// equivalent of the production handler's redirect catch.
|
|
215
|
+
const info = getRedirectInfo(err);
|
|
216
|
+
if (info) {
|
|
217
|
+
res.statusCode = info.status;
|
|
218
|
+
res.setHeader("Location", info.url);
|
|
219
|
+
res.end();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
153
222
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
154
223
|
server.ssrFixStacktrace(error);
|
|
155
224
|
const html = renderErrorOverlay(error);
|
|
@@ -254,11 +323,11 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
254
323
|
});
|
|
255
324
|
},
|
|
256
325
|
|
|
257
|
-
config(
|
|
326
|
+
config(viteUserConfig) {
|
|
258
327
|
// Discover all @pyreon/* packages installed in node_modules.
|
|
259
328
|
// The "bun" export condition points to TS source — esbuild's
|
|
260
329
|
// dep optimizer would compile them with the wrong JSX runtime.
|
|
261
|
-
const root =
|
|
330
|
+
const root = viteUserConfig.root ?? process.cwd()
|
|
262
331
|
const pyreonExclude = scanPyreonPackages(root)
|
|
263
332
|
|
|
264
333
|
// `@pyreon/runtime-server` is only imported by zero's dev SSR
|
|
@@ -292,9 +361,16 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
292
361
|
optimizeDeps: {
|
|
293
362
|
exclude: pyreonExclude,
|
|
294
363
|
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
364
|
+
// Only set the port when the user explicitly provided one in
|
|
365
|
+
// `zero({ port: N })`. Without this guard, the plugin always
|
|
366
|
+
// returned `server: { port: 3000 }` which overrode Vite's CLI
|
|
367
|
+
// `--port` flag and made multi-example dev impossible — every
|
|
368
|
+
// example tried to bind 3000 even when launched with
|
|
369
|
+
// `vite --port 5173`. Surfaced when wiring up the playwright
|
|
370
|
+
// e2e suite.
|
|
371
|
+
...(userConfig.port !== undefined
|
|
372
|
+
? { server: { port: config.port } }
|
|
373
|
+
: {}),
|
|
298
374
|
define: {
|
|
299
375
|
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
300
376
|
__ZERO_BASE__: JSON.stringify(config.base),
|
|
@@ -303,7 +379,112 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
303
379
|
},
|
|
304
380
|
};
|
|
305
381
|
|
|
306
|
-
|
|
382
|
+
// SSG mode auto-wires the static-site generation hook. Other modes get
|
|
383
|
+
// just the main plugin. The SSG plugin internally no-ops when
|
|
384
|
+
// `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
|
|
385
|
+
// minimal for SSR/SPA/ISR builds (one less `closeBundle` to call).
|
|
386
|
+
return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
|
|
391
|
+
* module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
|
|
392
|
+
* the matching route's HTTP-method handler.
|
|
393
|
+
*
|
|
394
|
+
* Returns `true` if the API middleware handled the request (response written).
|
|
395
|
+
* Returns `false` if no route matched (caller falls through to next middleware).
|
|
396
|
+
*
|
|
397
|
+
* Mirrors what `createServer` wires up in production via `createApiMiddleware`,
|
|
398
|
+
* but adapted for Vite's connect-style middleware stack — needs a Node→Web
|
|
399
|
+
* request adapter.
|
|
400
|
+
*/
|
|
401
|
+
async function dispatchApiRoute(
|
|
402
|
+
server: ViteDevServer,
|
|
403
|
+
req: IncomingMessage,
|
|
404
|
+
res: ServerResponse,
|
|
405
|
+
): Promise<boolean> {
|
|
406
|
+
let apiRoutes: ApiRouteEntry[];
|
|
407
|
+
try {
|
|
408
|
+
const mod = await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID);
|
|
409
|
+
apiRoutes = (mod.apiRoutes ?? []) as ApiRouteEntry[];
|
|
410
|
+
} catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
if (apiRoutes.length === 0) return false;
|
|
414
|
+
|
|
415
|
+
const host = req.headers.host ?? "localhost";
|
|
416
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
417
|
+
const pathname = url.pathname;
|
|
418
|
+
|
|
419
|
+
// Quick gate: only build the Web Request when the path actually matches
|
|
420
|
+
// an api route. Reuses the same `matchApiRoute` that `createApiMiddleware`
|
|
421
|
+
// uses internally — including catch-all `:param*` patterns from
|
|
422
|
+
// `[...slug].ts` API routes — so the gate and the dispatcher agree on
|
|
423
|
+
// what counts as a match. Avoids per-request body buffering for SSR /
|
|
424
|
+
// static traffic that doesn't target an API route.
|
|
425
|
+
const anyMatch = apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null);
|
|
426
|
+
if (!anyMatch) return false;
|
|
427
|
+
|
|
428
|
+
// Convert Node IncomingMessage → Web Request. Stream the request body
|
|
429
|
+
// for non-GET/HEAD via `Readable.toWeb` instead of buffering — large
|
|
430
|
+
// uploads (multipart, file POSTs) don't have to fit in memory before
|
|
431
|
+
// the handler sees them. `duplex: 'half'` is required by the WHATWG
|
|
432
|
+
// fetch spec when `body` is a `ReadableStream`.
|
|
433
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
434
|
+
const headers = new Headers();
|
|
435
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
436
|
+
if (value !== undefined) {
|
|
437
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const requestInit: RequestInit & { duplex?: "half" } = { method, headers };
|
|
441
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
442
|
+
// `Readable.toWeb` returns Node's `node:stream/web` `ReadableStream`;
|
|
443
|
+
// `RequestInit.body` expects the DOM `ReadableStream`. They're
|
|
444
|
+
// structurally identical at runtime but TS keeps them as separate
|
|
445
|
+
// types — `as unknown as` is the standard bridge per TS's own
|
|
446
|
+
// "convert to unknown first" suggestion.
|
|
447
|
+
requestInit.body = Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>;
|
|
448
|
+
requestInit.duplex = "half";
|
|
449
|
+
}
|
|
450
|
+
const webReq = new Request(url.href, requestInit);
|
|
451
|
+
|
|
452
|
+
const middleware = createApiMiddleware(apiRoutes);
|
|
453
|
+
const response = await middleware({
|
|
454
|
+
req: webReq,
|
|
455
|
+
url,
|
|
456
|
+
path: pathname + url.search,
|
|
457
|
+
headers: new Headers(),
|
|
458
|
+
locals: {},
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (!response) return false;
|
|
462
|
+
|
|
463
|
+
// Pipe the Web Response body directly to the Node response stream
|
|
464
|
+
// instead of buffering with `arrayBuffer()`. Critical for SSE, large
|
|
465
|
+
// downloads, and any handler that returns a `Response` constructed
|
|
466
|
+
// from a streaming source — buffering would defeat the streaming
|
|
467
|
+
// contract and OOM on large payloads.
|
|
468
|
+
res.statusCode = response.status;
|
|
469
|
+
response.headers.forEach((v, k) => {
|
|
470
|
+
res.setHeader(k, v);
|
|
471
|
+
});
|
|
472
|
+
if (response.body) {
|
|
473
|
+
// `pipe(res)` ends `res` automatically on stream completion and
|
|
474
|
+
// auto-cancels the upstream Web ReadableStream if the client
|
|
475
|
+
// disconnects (Node ≥18). We don't await — once the headers and
|
|
476
|
+
// pipe are wired, the function's job is done. The connect chain
|
|
477
|
+
// doesn't call `next()` because we resolved with `true`.
|
|
478
|
+
// `response.body` is a DOM `ReadableStream`; `Readable.fromWeb`
|
|
479
|
+
// expects `node:stream/web`'s `ReadableStream`. Cross-realm types
|
|
480
|
+
// don't unify in TS — bridge via `unknown` per TS's own guidance.
|
|
481
|
+
Readable.fromWeb(
|
|
482
|
+
response.body as unknown as import("node:stream/web").ReadableStream,
|
|
483
|
+
).pipe(res);
|
|
484
|
+
} else {
|
|
485
|
+
res.end();
|
|
486
|
+
}
|
|
487
|
+
return true;
|
|
307
488
|
}
|
|
308
489
|
|
|
309
490
|
/**
|
|
@@ -359,6 +540,7 @@ async function renderSsr(
|
|
|
359
540
|
root: string,
|
|
360
541
|
originalUrl: string,
|
|
361
542
|
pathname: string,
|
|
543
|
+
req?: Request,
|
|
362
544
|
): Promise<string | null> {
|
|
363
545
|
// Pattern check FIRST — otherwise SSR would try (and likely crash) on
|
|
364
546
|
// asset paths that happened to accept text/html (e.g. curl-style).
|
|
@@ -403,25 +585,19 @@ async function renderSsr(
|
|
|
403
585
|
],
|
|
404
586
|
);
|
|
405
587
|
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
}
|
|
588
|
+
// Don't auto-load `_layout.tsx` as the outer Layout. fs-router already
|
|
589
|
+
// emits it as a parent route in the matched chain — wrapping createApp
|
|
590
|
+
// with the same layout AGAIN produced a double mount: the App tree was
|
|
591
|
+
// `<Layout><RouterView/></Layout>` AND the router's first matched
|
|
592
|
+
// record was the layout, rendering it a second time inside RouterView.
|
|
593
|
+
// Symptom: duplicate `<nav>`, duplicate `<div id="layout">`,
|
|
594
|
+
// hydration mismatches, strict-mode locator violations in playwright.
|
|
595
|
+
//
|
|
596
|
+
// If a user genuinely needs an outer wrapper that sits ABOVE the
|
|
597
|
+
// router (e.g. a global provider not tied to any route), they can
|
|
598
|
+
// still pass it via `startClient({ layout })` — but the file should
|
|
599
|
+
// NOT live under `src/routes/_layout.{ts,tsx,…}` (which fs-router
|
|
600
|
+
// always treats as a parent route).
|
|
425
601
|
|
|
426
602
|
// Use zero's own `createApp` rather than reassembling the tree by hand —
|
|
427
603
|
// guarantees server and client agree on every wrapper component (any
|
|
@@ -435,20 +611,16 @@ async function renderSsr(
|
|
|
435
611
|
const appMod = (await server.ssrLoadModule(
|
|
436
612
|
"@pyreon/zero/server",
|
|
437
613
|
)) as typeof import("./server")
|
|
438
|
-
type CreateAppLayout = NonNullable<
|
|
439
|
-
Parameters<typeof appMod.createApp>[0]["layout"]
|
|
440
|
-
>
|
|
441
614
|
const { App, router: routerInst } = appMod.createApp({
|
|
442
615
|
routes: routes as import("@pyreon/router").RouteRecord[],
|
|
443
616
|
routerMode: "history",
|
|
444
617
|
url: pathname,
|
|
445
|
-
...(userLayout ? { layout: userLayout as CreateAppLayout } : {}),
|
|
446
618
|
})
|
|
447
619
|
|
|
448
620
|
// `preload` loads lazy route components AND runs loaders for `pathname` so
|
|
449
621
|
// the synchronous render pass produces final HTML — no loading fallbacks,
|
|
450
622
|
// no `useLoaderData() === undefined`.
|
|
451
|
-
await routerInst.preload(pathname);
|
|
623
|
+
await routerInst.preload(pathname, req);
|
|
452
624
|
|
|
453
625
|
return runtimeServer.runWithRequestContext(async () => {
|
|
454
626
|
const app = core.h(App as Parameters<typeof core.h>[0], null);
|
|
@@ -469,22 +641,30 @@ async function renderSsr(
|
|
|
469
641
|
});
|
|
470
642
|
}
|
|
471
643
|
|
|
472
|
-
/**
|
|
644
|
+
/**
|
|
645
|
+
* Extract all URL patterns from a nested route tree.
|
|
646
|
+
*
|
|
647
|
+
* The fs-router emits ABSOLUTE paths for every route, including grandchildren —
|
|
648
|
+
* `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
|
|
649
|
+
* each route's `path` as-is; no prefix accumulation. Pre-fix, this function
|
|
650
|
+
* concatenated `${prefix}${route.path}` which produced patterns like
|
|
651
|
+
* `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
|
|
652
|
+
* `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
|
|
653
|
+
* — which can't match a real `/app/dashboard` request — so dev-server returned
|
|
654
|
+
* 404 for every nested-layout route. Re-enables PR #411 specs that rely on
|
|
655
|
+
* `/app/*` routing.
|
|
656
|
+
*/
|
|
473
657
|
function flattenRoutePatterns(
|
|
474
658
|
routes: Array<{ path?: string; children?: unknown[] }>,
|
|
475
|
-
prefix = "",
|
|
476
659
|
): string[] {
|
|
477
660
|
const patterns: string[] = [];
|
|
478
661
|
for (const route of routes) {
|
|
479
662
|
if (!route.path) continue;
|
|
480
|
-
|
|
481
|
-
route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
482
|
-
patterns.push(fullPath);
|
|
663
|
+
patterns.push(route.path);
|
|
483
664
|
if (route.children) {
|
|
484
665
|
patterns.push(
|
|
485
666
|
...flattenRoutePatterns(
|
|
486
667
|
route.children as Array<{ path?: string; children?: unknown[] }>,
|
|
487
|
-
fullPath,
|
|
488
668
|
),
|
|
489
669
|
);
|
|
490
670
|
}
|
package/lib/actions.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"actions.js","names":[],"sources":["../src/actions.ts"],"sourcesContent":["import type { MiddlewareContext } from '@pyreon/server'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Context passed to server action handlers. */\nexport interface ActionContext {\n /** The original request. */\n request: Request\n /** Parsed form data (for form submissions). */\n formData: FormData | null\n /** Parsed JSON body (for JSON submissions). */\n json: unknown\n /** Request headers. */\n headers: Headers\n}\n\n/** A server action handler function. */\nexport type ActionHandler<T = unknown> = (ctx: ActionContext) => T | Promise<T>\n\n/** A registered action with its ID and handler. */\ninterface RegisteredAction {\n id: string\n handler: ActionHandler\n}\n\n/** Client-side callable action returned by defineAction. */\nexport interface Action<T = unknown> {\n /** Call the action with JSON data. */\n (data?: unknown): Promise<T>\n /** The action's unique ID. */\n actionId: string\n}\n\n// ─── Registry ────────────────────────────────────────────────────────────────\n\nconst actionRegistry = new Map<string, RegisteredAction>()\n\n/**\n * Define a server action. Returns a callable function that:\n * - On the **client**: sends a POST request to `/_zero/actions/<id>`\n * - On the **server** (SSR): executes the handler directly (no fetch)\n *\n * @example\n * // In a route file or module:\n * export const createPost = defineAction(async (ctx) => {\n * const data = ctx.json as { title: string; body: string }\n * // ... save to database\n * return { success: true, id: 123 }\n * })\n *\n * // In a component:\n * const result = await createPost({ title: 'Hello', body: '...' })\n */\nexport function defineAction<T = unknown>(handler: ActionHandler<T>): Action<T> {\n const id = `action_${crypto.randomUUID().slice(0, 8)}`\n\n actionRegistry.set(id, { id, handler: handler as ActionHandler })\n\n const callable = async (data?: unknown): Promise<T> => {\n // Server-side: execute handler directly (no network round-trip)\n if (typeof globalThis.window === 'undefined') {\n return handler({\n request: new Request(`http://localhost/_zero/actions/${id}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data ?? null),\n }),\n formData: null,\n json: data ?? null,\n headers: new Headers({ 'Content-Type': 'application/json' }),\n })\n }\n\n // Client-side: POST to the action endpoint\n const response = await fetch(`/_zero/actions/${id}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data ?? null),\n })\n if (!response.ok) {\n const body = await response.json().catch(() => ({}))\n throw new Error((body as { error?: string }).error ?? `Action failed: ${response.statusText}`)\n }\n return response.json()\n }\n\n callable.actionId = id\n return callable as Action<T>\n}\n\n/** Get all registered actions. Useful for testing. */\nexport function getRegisteredActions(): Map<string, RegisteredAction> {\n return actionRegistry\n}\n\n/**\n * Reset the action registry. Useful for testing.\n * @internal\n */\nexport function _resetActions(): void {\n actionRegistry.clear()\n}\n\n// ─── Server handler ──────────────────────────────────────────────────────────\n\n/**\n * Create a middleware that handles action requests at `/_zero/actions/*`.\n * Mount this before the SSR handler in the server entry.\n */\nexport function createActionMiddleware(): (\n ctx: MiddlewareContext,\n) => Response | undefined | Promise<Response | undefined> {\n return async (ctx: MiddlewareContext) => {\n if (!ctx.path.startsWith('/_zero/actions/')) return\n\n const actionId = ctx.path.slice('/_zero/actions/'.length)\n const action = actionRegistry.get(actionId)\n\n if (!action) {\n return Response.json({ error: 'Action not found' }, { status: 404 })\n }\n\n if (ctx.req.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n\n return executeAction(action, ctx.req)\n }\n}\n\nasync function executeAction(action: RegisteredAction, req: Request): Promise<Response> {\n try {\n const contentType = req.headers.get('content-type') ?? ''\n let formData: FormData | null = null\n let json: unknown = null\n\n if (contentType.includes('application/json')) {\n json = await req.json()\n } else if (\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded')\n ) {\n formData = await req.formData()\n }\n\n const result = await action.handler({\n request: req,\n formData,\n json,\n headers: req.headers,\n })\n\n return Response.json(result ?? null)\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Internal server error'\n return Response.json({ error: message }, { status: 500 })\n }\n}\n"],"mappings":";AAmCA,MAAM,iCAAiB,IAAI,KAA+B;;;;;;;;;;;;;;;;;AAkB1D,SAAgB,aAA0B,SAAsC;CAC9E,MAAM,KAAK,UAAU,OAAO,YAAY,CAAC,MAAM,GAAG,EAAE;AAEpD,gBAAe,IAAI,IAAI;EAAE;EAAa;EAA0B,CAAC;CAEjE,MAAM,WAAW,OAAO,SAA+B;AAErD,MAAI,OAAO,WAAW,WAAW,YAC/B,QAAO,QAAQ;GACb,SAAS,IAAI,QAAQ,kCAAkC,MAAM;IAC3D,QAAQ;IACR,SAAS,EAAE,gBAAgB,oBAAoB;IAC/C,MAAM,KAAK,UAAU,QAAQ,KAAK;IACnC,CAAC;GACF,UAAU;GACV,MAAM,QAAQ;GACd,SAAS,IAAI,QAAQ,EAAE,gBAAgB,oBAAoB,CAAC;GAC7D,CAAC;EAIJ,MAAM,WAAW,MAAM,MAAM,kBAAkB,MAAM;GACnD,QAAQ;GACR,SAAS,EAAE,gBAAgB,oBAAoB;GAC/C,MAAM,KAAK,UAAU,QAAQ,KAAK;GACnC,CAAC;AACF,MAAI,CAAC,SAAS,IAAI;GAChB,MAAM,OAAO,MAAM,SAAS,MAAM,CAAC,aAAa,EAAE,EAAE;AACpD,SAAM,IAAI,MAAO,KAA4B,SAAS,kBAAkB,SAAS,aAAa;;AAEhG,SAAO,SAAS,MAAM;;AAGxB,UAAS,WAAW;AACpB,QAAO;;;AAIT,SAAgB,uBAAsD;AACpE,QAAO;;;;;;AAOT,SAAgB,gBAAsB;AACpC,gBAAe,OAAO;;;;;;AASxB,SAAgB,yBAE0C;AACxD,QAAO,OAAO,QAA2B;AACvC,MAAI,CAAC,IAAI,KAAK,WAAW,kBAAkB,CAAE;EAE7C,MAAM,WAAW,IAAI,KAAK,MAAM,GAAyB;EACzD,MAAM,SAAS,eAAe,IAAI,SAAS;AAE3C,MAAI,CAAC,OACH,QAAO,SAAS,KAAK,EAAE,OAAO,oBAAoB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAGtE,MAAI,IAAI,IAAI,WAAW,OACrB,QAAO,SAAS,KAAK,EAAE,OAAO,sBAAsB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAGxE,SAAO,cAAc,QAAQ,IAAI,IAAI;;;AAIzC,eAAe,cAAc,QAA0B,KAAiC;AACtF,KAAI;EACF,MAAM,cAAc,IAAI,QAAQ,IAAI,eAAe,IAAI;EACvD,IAAI,WAA4B;EAChC,IAAI,OAAgB;AAEpB,MAAI,YAAY,SAAS,mBAAmB,CAC1C,QAAO,MAAM,IAAI,MAAM;WAEvB,YAAY,SAAS,sBAAsB,IAC3C,YAAY,SAAS,oCAAoC,CAEzD,YAAW,MAAM,IAAI,UAAU;EAGjC,MAAM,SAAS,MAAM,OAAO,QAAQ;GAClC,SAAS;GACT;GACA;GACA,SAAS,IAAI;GACd,CAAC;AAEF,SAAO,SAAS,KAAK,UAAU,KAAK;UAC7B,KAAK;EACZ,MAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,SAAO,SAAS,KAAK,EAAE,OAAO,SAAS,EAAE,EAAE,QAAQ,KAAK,CAAC"}
|