@pyreon/zero 0.14.0 → 0.16.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-Ci0kVmM4.js +146 -0
- package/lib/client.js +7 -2
- package/lib/csp.js +19 -9
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
- package/lib/i18n-routing.js +112 -1
- package/lib/image-plugin.js +4 -0
- package/lib/image.js +141 -108
- package/lib/index.js +253 -132
- package/lib/link.js +1 -49
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +115 -74
- package/lib/seo.js +186 -15
- package/lib/server.js +275 -1247
- package/lib/theme.js +1 -50
- package/lib/types/config.d.ts +275 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +197 -6
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +640 -178
- package/lib/types/link.d.ts +3 -3
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +603 -77
- package/lib/types/theme.d.ts +2 -2
- package/lib/vite-plugin-xjWZwudX.js +2454 -0
- package/package.json +16 -13
- package/src/adapters/bun.ts +20 -1
- package/src/adapters/cloudflare.ts +78 -1
- package/src/adapters/index.ts +25 -3
- package/src/adapters/netlify.ts +63 -1
- package/src/adapters/node.ts +25 -1
- package/src/adapters/static.ts +26 -1
- package/src/adapters/validate.ts +8 -1
- package/src/adapters/vercel.ts +76 -1
- package/src/adapters/warn-missing-env.ts +49 -0
- package/src/app.ts +35 -1
- package/src/client.ts +18 -0
- package/src/csp.ts +28 -12
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +123 -4
- package/src/i18n-routing.ts +246 -12
- package/src/image.tsx +242 -91
- package/src/index.ts +4 -4
- package/src/isr.ts +24 -6
- package/src/manifest.ts +675 -0
- package/src/og-image.ts +5 -5
- package/src/script.tsx +159 -36
- package/src/seo.ts +346 -15
- package/src/server.ts +10 -2
- package/src/ssg-plugin.ts +1523 -0
- package/src/types.ts +329 -19
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +326 -68
- 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/vite-plugin.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
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'
|
|
17
|
+
import { matchPattern } from './entry-server'
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
20
|
* Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
|
|
@@ -36,7 +47,6 @@ function resolveNestedPackage(root: string, name: string): string | undefined {
|
|
|
36
47
|
if (existsSync(nested)) return nested
|
|
37
48
|
return undefined
|
|
38
49
|
}
|
|
39
|
-
import { matchPattern } from "./entry-server";
|
|
40
50
|
import { renderErrorOverlay } from "./error-overlay";
|
|
41
51
|
import {
|
|
42
52
|
generateMiddlewareModule,
|
|
@@ -44,7 +54,9 @@ import {
|
|
|
44
54
|
scanRouteFiles,
|
|
45
55
|
scanRouteFilesWithExports,
|
|
46
56
|
} from "./fs-router";
|
|
57
|
+
import { expandRoutesForLocales } from "./i18n-routing";
|
|
47
58
|
import { render404Page } from "./not-found";
|
|
59
|
+
import { ssgPlugin } from "./ssg-plugin";
|
|
48
60
|
import type { ZeroConfig } from "./types";
|
|
49
61
|
|
|
50
62
|
const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
|
|
@@ -56,6 +68,28 @@ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
|
|
|
56
68
|
const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
|
|
57
69
|
const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
|
|
58
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
|
+
|
|
59
93
|
/**
|
|
60
94
|
* Zero Vite plugin — adds file-based routing and zero-config conventions
|
|
61
95
|
* on top of @pyreon/vite-plugin.
|
|
@@ -69,15 +103,14 @@ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
|
|
|
69
103
|
* plugins: [pyreon(), zero()],
|
|
70
104
|
* }
|
|
71
105
|
*/
|
|
72
|
-
export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
106
|
+
export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
|
|
73
107
|
const config = resolveConfig(userConfig);
|
|
74
108
|
let routesDir: string;
|
|
75
109
|
let root: string;
|
|
76
110
|
|
|
77
|
-
const
|
|
111
|
+
const mainPlugin: Plugin = {
|
|
78
112
|
name: "pyreon-zero",
|
|
79
113
|
enforce: "pre",
|
|
80
|
-
_zeroConfig: userConfig,
|
|
81
114
|
|
|
82
115
|
configResolved(resolvedConfig) {
|
|
83
116
|
root = resolvedConfig.root;
|
|
@@ -98,7 +131,13 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
98
131
|
// • lazy() for routes that only export `default` (best code splitting)
|
|
99
132
|
// • Direct mod.loader/.guard/.meta access for routes with metadata
|
|
100
133
|
// • No spurious IMPORT_IS_UNDEFINED warnings from Rolldown
|
|
101
|
-
const
|
|
134
|
+
const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
|
|
135
|
+
// PR H — fan routes into per-locale variants when `i18n` is
|
|
136
|
+
// configured. No-op when unset; identity-returns the input
|
|
137
|
+
// otherwise so existing apps see byte-identical output.
|
|
138
|
+
const routes = config.i18n
|
|
139
|
+
? expandRoutesForLocales(baseRoutes, config.i18n)
|
|
140
|
+
: baseRoutes;
|
|
102
141
|
return generateRouteModuleFromRoutes(routes, routesDir, {
|
|
103
142
|
staticImports: config.mode === 'ssg',
|
|
104
143
|
});
|
|
@@ -127,6 +166,35 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
127
166
|
},
|
|
128
167
|
|
|
129
168
|
configureServer(server) {
|
|
169
|
+
// Dev-mode API-route middleware — production wires `createApiMiddleware`
|
|
170
|
+
// via `createServer`, but dev had no equivalent. API requests fell
|
|
171
|
+
// through to Vite's default 404. This middleware loads the
|
|
172
|
+
// `virtual:zero/api-routes` module and dispatches matching requests to
|
|
173
|
+
// the route's `GET()` / `POST()` / etc. handler. Mirrors the production
|
|
174
|
+
// flow in `entry-server.ts` minus the ergonomic helpers (auth, cors,
|
|
175
|
+
// etc. — those plug in via user-defined Pyreon middleware which dev
|
|
176
|
+
// doesn't currently load; not in scope here).
|
|
177
|
+
//
|
|
178
|
+
// Registered FIRST so API requests don't get SSR'd or 404'd.
|
|
179
|
+
server.middlewares.use((req, res, next) => {
|
|
180
|
+
const pathname = req.url?.split("?")[0] ?? "/";
|
|
181
|
+
if (pathname.startsWith("/@") || pathname.startsWith("/__"))
|
|
182
|
+
return next();
|
|
183
|
+
// Skip files (extension-bearing) — let Vite's static pipeline serve.
|
|
184
|
+
if (/\.\w+$/.test(pathname)) return next();
|
|
185
|
+
|
|
186
|
+
dispatchApiRoute(server, req, res).then(
|
|
187
|
+
(handled) => {
|
|
188
|
+
if (!handled) next();
|
|
189
|
+
},
|
|
190
|
+
(err: unknown) => {
|
|
191
|
+
// oxlint-disable-next-line no-console
|
|
192
|
+
console.error("[Pyreon] Error in dev API dispatcher:", err);
|
|
193
|
+
next();
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
130
198
|
// Dev-mode SSR middleware — for mode: "ssr", actually render each
|
|
131
199
|
// matched route server-side instead of serving the SPA shell.
|
|
132
200
|
// Runs BEFORE the 404 handler so matched routes are SSR'd and
|
|
@@ -141,15 +209,44 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
141
209
|
return next();
|
|
142
210
|
if (/\.\w+$/.test(pathname)) return next();
|
|
143
211
|
|
|
144
|
-
|
|
212
|
+
// Build a Web Request from the Node IncomingMessage so loaders
|
|
213
|
+
// can read cookies / auth headers via `ctx.request` and call
|
|
214
|
+
// `redirect()` from a server-side context.
|
|
215
|
+
const reqHost = req.headers.host ?? "localhost";
|
|
216
|
+
const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
|
|
217
|
+
const reqHeaders = new Headers();
|
|
218
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
219
|
+
if (value !== undefined) {
|
|
220
|
+
reqHeaders.set(
|
|
221
|
+
key,
|
|
222
|
+
Array.isArray(value) ? value.join(", ") : String(value),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const webReq = new Request(reqUrl.href, {
|
|
227
|
+
method: req.method ?? "GET",
|
|
228
|
+
headers: reqHeaders,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
|
|
145
232
|
(result) => {
|
|
146
233
|
if (result === null) return next();
|
|
147
|
-
res.statusCode =
|
|
234
|
+
res.statusCode = result.status;
|
|
148
235
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
149
|
-
res.setHeader("Content-Length", Buffer.byteLength(result));
|
|
150
|
-
res.end(result);
|
|
236
|
+
res.setHeader("Content-Length", Buffer.byteLength(result.html));
|
|
237
|
+
res.end(result.html);
|
|
151
238
|
},
|
|
152
239
|
(err: unknown) => {
|
|
240
|
+
// Loader-thrown `redirect()` — convert to a real HTTP redirect
|
|
241
|
+
// (302/307/308) BEFORE the layout renders. This is the dev-mode
|
|
242
|
+
// equivalent of the production handler's redirect catch.
|
|
243
|
+
const info = getRedirectInfo(err);
|
|
244
|
+
if (info) {
|
|
245
|
+
res.statusCode = info.status;
|
|
246
|
+
res.setHeader("Location", info.url);
|
|
247
|
+
res.end();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
153
250
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
154
251
|
server.ssrFixStacktrace(error);
|
|
155
252
|
const html = renderErrorOverlay(error);
|
|
@@ -254,19 +351,19 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
254
351
|
});
|
|
255
352
|
},
|
|
256
353
|
|
|
257
|
-
config(
|
|
354
|
+
config(viteUserConfig) {
|
|
258
355
|
// Discover all @pyreon/* packages installed in node_modules.
|
|
259
356
|
// The "bun" export condition points to TS source — esbuild's
|
|
260
357
|
// dep optimizer would compile them with the wrong JSX runtime.
|
|
261
|
-
const
|
|
262
|
-
const pyreonExclude = scanPyreonPackages(
|
|
358
|
+
const cwd = viteUserConfig.root ?? process.cwd()
|
|
359
|
+
const pyreonExclude = scanPyreonPackages(cwd)
|
|
263
360
|
|
|
264
361
|
// `@pyreon/runtime-server` is only imported by zero's dev SSR
|
|
265
362
|
// middleware and the production server entry — apps rarely list it
|
|
266
363
|
// as a direct dep. Resolve it to the copy nested under zero so
|
|
267
364
|
// `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
|
|
268
365
|
const runtimeServerAlias = resolveNestedPackage(
|
|
269
|
-
|
|
366
|
+
cwd,
|
|
270
367
|
"@pyreon/runtime-server",
|
|
271
368
|
)
|
|
272
369
|
|
|
@@ -292,9 +389,35 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
292
389
|
optimizeDeps: {
|
|
293
390
|
exclude: pyreonExclude,
|
|
294
391
|
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
392
|
+
// Only set the port when the user explicitly provided one in
|
|
393
|
+
// `zero({ port: N })`. Without this guard, the plugin always
|
|
394
|
+
// returned `server: { port: 3000 }` which overrode Vite's CLI
|
|
395
|
+
// `--port` flag and made multi-example dev impossible — every
|
|
396
|
+
// example tried to bind 3000 even when launched with
|
|
397
|
+
// `vite --port 5173`. Surfaced when wiring up the playwright
|
|
398
|
+
// e2e suite.
|
|
399
|
+
...(userConfig.port !== undefined
|
|
400
|
+
? { server: { port: config.port } }
|
|
401
|
+
: {}),
|
|
402
|
+
// Propagate `zero({ base })` to Vite's `base` config — that's
|
|
403
|
+
// what controls asset URL rewriting in the built HTML/JS
|
|
404
|
+
// (`<script src="/blog/assets/…">`). Pre-fix this was a
|
|
405
|
+
// typed-but-unimplemented field: `__ZERO_BASE__` was defined
|
|
406
|
+
// as a Vite global but no consumer existed, AND Vite's own
|
|
407
|
+
// `base` had to be set manually in vite.config.ts. Setting
|
|
408
|
+
// it here makes `zero({ base: '/blog/' })` the canonical
|
|
409
|
+
// single-source-of-truth surface; the value flows through
|
|
410
|
+
// to (a) Vite's HTML/asset URL rewriter, (b) `createRouter`
|
|
411
|
+
// via `__ZERO_BASE__` in `startClient` / `createApp`, (c)
|
|
412
|
+
// the SSG entry's `createApp({ base })` call.
|
|
413
|
+
//
|
|
414
|
+
// Vite's config-merge semantics: plugin-returned config is
|
|
415
|
+
// the BASE; user's `vite.config.ts` top-level overrides.
|
|
416
|
+
// So a user who sets BOTH `zero({ base: '/blog/' })` AND
|
|
417
|
+
// `vite.config.base: '/foo/'` gets the latter — the user's
|
|
418
|
+
// explicit override wins. The default `/` is a no-op
|
|
419
|
+
// (matches Vite's default), so always-setting is safe.
|
|
420
|
+
base: config.base,
|
|
298
421
|
define: {
|
|
299
422
|
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
300
423
|
__ZERO_BASE__: JSON.stringify(config.base),
|
|
@@ -303,17 +426,139 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
303
426
|
},
|
|
304
427
|
};
|
|
305
428
|
|
|
306
|
-
|
|
429
|
+
// Stash the original user config keyed by plugin identity so the CLI
|
|
430
|
+
// (which loads vite.config.ts and inspects the plugin list) can
|
|
431
|
+
// recover it via `getZeroPluginConfig(plugin)` without us hanging a
|
|
432
|
+
// `_`-prefixed property off the public Plugin object.
|
|
433
|
+
zeroPluginConfigMap.set(mainPlugin, userConfig);
|
|
434
|
+
|
|
435
|
+
// SSG mode auto-wires the static-site generation hook. Other modes get
|
|
436
|
+
// just the main plugin. The SSG plugin internally no-ops when
|
|
437
|
+
// `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
|
|
438
|
+
// minimal for SSR/SPA/ISR builds (one less `closeBundle` to call).
|
|
439
|
+
return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
|
|
444
|
+
* module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
|
|
445
|
+
* the matching route's HTTP-method handler.
|
|
446
|
+
*
|
|
447
|
+
* Returns `true` if the API middleware handled the request (response written).
|
|
448
|
+
* Returns `false` if no route matched (caller falls through to next middleware).
|
|
449
|
+
*
|
|
450
|
+
* Mirrors what `createServer` wires up in production via `createApiMiddleware`,
|
|
451
|
+
* but adapted for Vite's connect-style middleware stack — needs a Node→Web
|
|
452
|
+
* request adapter.
|
|
453
|
+
*/
|
|
454
|
+
async function dispatchApiRoute(
|
|
455
|
+
server: ViteDevServer,
|
|
456
|
+
req: IncomingMessage,
|
|
457
|
+
res: ServerResponse,
|
|
458
|
+
): Promise<boolean> {
|
|
459
|
+
let apiRoutes: ApiRouteEntry[];
|
|
460
|
+
try {
|
|
461
|
+
const mod = await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID);
|
|
462
|
+
apiRoutes = (mod.apiRoutes ?? []) as ApiRouteEntry[];
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
if (apiRoutes.length === 0) return false;
|
|
467
|
+
|
|
468
|
+
const host = req.headers.host ?? "localhost";
|
|
469
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
470
|
+
const pathname = url.pathname;
|
|
471
|
+
|
|
472
|
+
// Quick gate: only build the Web Request when the path actually matches
|
|
473
|
+
// an api route. Reuses the same `matchApiRoute` that `createApiMiddleware`
|
|
474
|
+
// uses internally — including catch-all `:param*` patterns from
|
|
475
|
+
// `[...slug].ts` API routes — so the gate and the dispatcher agree on
|
|
476
|
+
// what counts as a match. Avoids per-request body buffering for SSR /
|
|
477
|
+
// static traffic that doesn't target an API route.
|
|
478
|
+
const anyMatch = apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null);
|
|
479
|
+
if (!anyMatch) return false;
|
|
480
|
+
|
|
481
|
+
// Convert Node IncomingMessage → Web Request. Stream the request body
|
|
482
|
+
// for non-GET/HEAD via `Readable.toWeb` instead of buffering — large
|
|
483
|
+
// uploads (multipart, file POSTs) don't have to fit in memory before
|
|
484
|
+
// the handler sees them. `duplex: 'half'` is required by the WHATWG
|
|
485
|
+
// fetch spec when `body` is a `ReadableStream`.
|
|
486
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
487
|
+
const headers = new Headers();
|
|
488
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
489
|
+
if (value !== undefined) {
|
|
490
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const requestInit: RequestInit & { duplex?: "half" } = { method, headers };
|
|
494
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
495
|
+
// `Readable.toWeb` returns Node's `node:stream/web` `ReadableStream`;
|
|
496
|
+
// `RequestInit.body` expects the DOM `ReadableStream`. They're
|
|
497
|
+
// structurally identical at runtime but TS keeps them as separate
|
|
498
|
+
// types — `as unknown as` is the standard bridge per TS's own
|
|
499
|
+
// "convert to unknown first" suggestion.
|
|
500
|
+
requestInit.body = Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>;
|
|
501
|
+
requestInit.duplex = "half";
|
|
502
|
+
}
|
|
503
|
+
const webReq = new Request(url.href, requestInit);
|
|
504
|
+
|
|
505
|
+
const middleware = createApiMiddleware(apiRoutes);
|
|
506
|
+
const response = await middleware({
|
|
507
|
+
req: webReq,
|
|
508
|
+
url,
|
|
509
|
+
path: pathname + url.search,
|
|
510
|
+
headers: new Headers(),
|
|
511
|
+
locals: {},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
if (!response) return false;
|
|
515
|
+
|
|
516
|
+
// Pipe the Web Response body directly to the Node response stream
|
|
517
|
+
// instead of buffering with `arrayBuffer()`. Critical for SSE, large
|
|
518
|
+
// downloads, and any handler that returns a `Response` constructed
|
|
519
|
+
// from a streaming source — buffering would defeat the streaming
|
|
520
|
+
// contract and OOM on large payloads.
|
|
521
|
+
res.statusCode = response.status;
|
|
522
|
+
response.headers.forEach((v, k) => {
|
|
523
|
+
res.setHeader(k, v);
|
|
524
|
+
});
|
|
525
|
+
if (response.body) {
|
|
526
|
+
// `pipe(res)` ends `res` automatically on stream completion and
|
|
527
|
+
// auto-cancels the upstream Web ReadableStream if the client
|
|
528
|
+
// disconnects (Node ≥18). We don't await — once the headers and
|
|
529
|
+
// pipe are wired, the function's job is done. The connect chain
|
|
530
|
+
// doesn't call `next()` because we resolved with `true`.
|
|
531
|
+
// `response.body` is a DOM `ReadableStream`; `Readable.fromWeb`
|
|
532
|
+
// expects `node:stream/web`'s `ReadableStream`. Cross-realm types
|
|
533
|
+
// don't unify in TS — bridge via `unknown` per TS's own guidance.
|
|
534
|
+
Readable.fromWeb(
|
|
535
|
+
response.body as unknown as import("node:stream/web").ReadableStream,
|
|
536
|
+
).pipe(res);
|
|
537
|
+
} else {
|
|
538
|
+
res.end();
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
307
541
|
}
|
|
308
542
|
|
|
309
543
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
544
|
+
* Static-page 404 fallback for apps WITHOUT `_404.tsx` in the routes tree.
|
|
545
|
+
*
|
|
546
|
+
* For `mode: 'ssr'` apps with `_404.tsx`, the SSR middleware's `renderSsr`
|
|
547
|
+
* routes unmatched URLs through the router-driven path (PR L5 + M1.2) — that
|
|
548
|
+
* produces a layout-wrapped 404 with HTTP status 404, never reaching here.
|
|
549
|
+
* This function is the LEGACY fallback that fires only when:
|
|
550
|
+
* - The app is in `mode: 'spa'` / `mode: 'ssg'` (no dev SSR middleware), OR
|
|
551
|
+
* - The app has no reachable `notFoundComponent` in its routes tree (so the
|
|
552
|
+
* SSR middleware's `resolveRoute` returns matched: [] and falls through).
|
|
553
|
+
*
|
|
554
|
+
* Returns true if the 404 was handled (response sent), false if the path
|
|
555
|
+
* actually matches a route (caller continues to next middleware).
|
|
312
556
|
*
|
|
313
|
-
*
|
|
314
|
-
* the compiler emits _tpl() calls that require
|
|
315
|
-
*
|
|
316
|
-
*
|
|
557
|
+
* Pre-M1.2 a stale comment claimed `_404.tsx` "cannot be SSR-rendered because
|
|
558
|
+
* the compiler emits _tpl() calls that require document". That was wrong — the
|
|
559
|
+
* SSR runtime renders compiler-emitted components fine via `renderToString`
|
|
560
|
+
* (no document needed). The static fallback exists for backward compat with
|
|
561
|
+
* apps that don't ship `_404.tsx`, not because SSR-rendering it is impossible.
|
|
317
562
|
*/
|
|
318
563
|
async function handle404(
|
|
319
564
|
server: import("vite").ViteDevServer,
|
|
@@ -329,10 +574,11 @@ async function handle404(
|
|
|
329
574
|
return false; // Route matches — not a 404
|
|
330
575
|
}
|
|
331
576
|
|
|
332
|
-
// No route matched —
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
577
|
+
// No route matched + no `_404.tsx` reachable — emit a minimal static page
|
|
578
|
+
// so the user gets SOMETHING. Apps that want branded 404s should add
|
|
579
|
+
// `_404.tsx` to their routes tree (canonical pattern); the SSR middleware
|
|
580
|
+
// then routes through the router-driven path with layout chrome instead
|
|
581
|
+
// of landing here.
|
|
336
582
|
const html = await render404Page(undefined);
|
|
337
583
|
|
|
338
584
|
res.statusCode = 404;
|
|
@@ -359,18 +605,13 @@ async function renderSsr(
|
|
|
359
605
|
root: string,
|
|
360
606
|
originalUrl: string,
|
|
361
607
|
pathname: string,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
// asset paths that happened to accept text/html (e.g. curl-style).
|
|
608
|
+
req?: Request,
|
|
609
|
+
): Promise<{ html: string; status: number } | null> {
|
|
365
610
|
const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
|
|
366
611
|
const routes = routesMod.routes as Array<{
|
|
367
612
|
path?: string;
|
|
368
613
|
children?: unknown[];
|
|
369
614
|
}>;
|
|
370
|
-
const patterns = flattenRoutePatterns(routes);
|
|
371
|
-
if (!patterns.some((pattern) => matchPattern(pattern, pathname))) {
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
374
615
|
|
|
375
616
|
// Read + transform index.html (Vite injects the HMR client / JSX prelude).
|
|
376
617
|
let template = await readFile(join(root, "index.html"), "utf-8");
|
|
@@ -383,7 +624,7 @@ async function renderSsr(
|
|
|
383
624
|
// `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
|
|
384
625
|
// `config()` hook registers an alias that points it at the copy under
|
|
385
626
|
// zero's own `node_modules` — same path → same Vite module → same instance.
|
|
386
|
-
const [core,
|
|
627
|
+
const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
|
|
387
628
|
[
|
|
388
629
|
server.ssrLoadModule("@pyreon/core") as Promise<
|
|
389
630
|
typeof import("@pyreon/core")
|
|
@@ -403,25 +644,19 @@ async function renderSsr(
|
|
|
403
644
|
],
|
|
404
645
|
);
|
|
405
646
|
|
|
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
|
-
}
|
|
647
|
+
// Don't auto-load `_layout.tsx` as the outer Layout. fs-router already
|
|
648
|
+
// emits it as a parent route in the matched chain — wrapping createApp
|
|
649
|
+
// with the same layout AGAIN produced a double mount: the App tree was
|
|
650
|
+
// `<Layout><RouterView/></Layout>` AND the router's first matched
|
|
651
|
+
// record was the layout, rendering it a second time inside RouterView.
|
|
652
|
+
// Symptom: duplicate `<nav>`, duplicate `<div id="layout">`,
|
|
653
|
+
// hydration mismatches, strict-mode locator violations in playwright.
|
|
654
|
+
//
|
|
655
|
+
// If a user genuinely needs an outer wrapper that sits ABOVE the
|
|
656
|
+
// router (e.g. a global provider not tied to any route), they can
|
|
657
|
+
// still pass it via `startClient({ layout })` — but the file should
|
|
658
|
+
// NOT live under `src/routes/_layout.{ts,tsx,…}` (which fs-router
|
|
659
|
+
// always treats as a parent route).
|
|
425
660
|
|
|
426
661
|
// Use zero's own `createApp` rather than reassembling the tree by hand —
|
|
427
662
|
// guarantees server and client agree on every wrapper component (any
|
|
@@ -435,20 +670,33 @@ async function renderSsr(
|
|
|
435
670
|
const appMod = (await server.ssrLoadModule(
|
|
436
671
|
"@pyreon/zero/server",
|
|
437
672
|
)) as typeof import("./server")
|
|
438
|
-
type CreateAppLayout = NonNullable<
|
|
439
|
-
Parameters<typeof appMod.createApp>[0]["layout"]
|
|
440
|
-
>
|
|
441
673
|
const { App, router: routerInst } = appMod.createApp({
|
|
442
674
|
routes: routes as import("@pyreon/router").RouteRecord[],
|
|
443
675
|
routerMode: "history",
|
|
444
676
|
url: pathname,
|
|
445
|
-
...(userLayout ? { layout: userLayout as CreateAppLayout } : {}),
|
|
446
677
|
})
|
|
447
678
|
|
|
448
679
|
// `preload` loads lazy route components AND runs loaders for `pathname` so
|
|
449
680
|
// the synchronous render pass produces final HTML — no loading fallbacks,
|
|
450
681
|
// no `useLoaderData() === undefined`.
|
|
451
|
-
|
|
682
|
+
//
|
|
683
|
+
// M1.2 — Unmatched URLs no longer bail to a static 404 page. The router's
|
|
684
|
+
// `resolveRoute` (PR L5) walks the route tree and, if a parent layout has
|
|
685
|
+
// `notFoundComponent` AND the URL is under that layout's prefix, builds a
|
|
686
|
+
// synthetic chain `[...ancestorLayouts, syntheticLeaf]` with
|
|
687
|
+
// `isNotFound: true`. The render then produces 404 HTML INSIDE the
|
|
688
|
+
// layout's chrome. If the routes tree has no reachable `notFoundComponent`,
|
|
689
|
+
// `matched` stays empty — fall through to `handle404` for the static
|
|
690
|
+
// fallback (preserves backward compat for apps without `_404.tsx`).
|
|
691
|
+
await routerInst.preload(pathname, req);
|
|
692
|
+
|
|
693
|
+
const resolved = routerInst.currentRoute() as
|
|
694
|
+
| { matched?: unknown[]; isNotFound?: boolean }
|
|
695
|
+
| undefined;
|
|
696
|
+
if (!resolved?.matched || resolved.matched.length === 0) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
const status = resolved.isNotFound === true ? 404 : 200;
|
|
452
700
|
|
|
453
701
|
return runtimeServer.runWithRequestContext(async () => {
|
|
454
702
|
const app = core.h(App as Parameters<typeof core.h>[0], null);
|
|
@@ -458,33 +706,43 @@ async function renderSsr(
|
|
|
458
706
|
routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
|
|
459
707
|
);
|
|
460
708
|
const hasData = loaderData && Object.keys(loaderData).length > 0;
|
|
709
|
+
// M2.2 — safe serializer (parity with production handler / SSG entry).
|
|
461
710
|
const loaderScript = hasData
|
|
462
|
-
? `<script>window.__PYREON_LOADER_DATA__=${
|
|
711
|
+
? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}</script>`
|
|
463
712
|
: "";
|
|
464
713
|
|
|
465
|
-
|
|
714
|
+
const html = template
|
|
466
715
|
.replace("<!--pyreon-head-->", head)
|
|
467
716
|
.replace("<!--pyreon-app-->", appHtml)
|
|
468
717
|
.replace("<!--pyreon-scripts-->", loaderScript);
|
|
718
|
+
return { html, status };
|
|
469
719
|
});
|
|
470
720
|
}
|
|
471
721
|
|
|
472
|
-
/**
|
|
722
|
+
/**
|
|
723
|
+
* Extract all URL patterns from a nested route tree.
|
|
724
|
+
*
|
|
725
|
+
* The fs-router emits ABSOLUTE paths for every route, including grandchildren —
|
|
726
|
+
* `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
|
|
727
|
+
* each route's `path` as-is; no prefix accumulation. Pre-fix, this function
|
|
728
|
+
* concatenated `${prefix}${route.path}` which produced patterns like
|
|
729
|
+
* `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
|
|
730
|
+
* `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
|
|
731
|
+
* — which can't match a real `/app/dashboard` request — so dev-server returned
|
|
732
|
+
* 404 for every nested-layout route. Re-enables PR #411 specs that rely on
|
|
733
|
+
* `/app/*` routing.
|
|
734
|
+
*/
|
|
473
735
|
function flattenRoutePatterns(
|
|
474
736
|
routes: Array<{ path?: string; children?: unknown[] }>,
|
|
475
|
-
prefix = "",
|
|
476
737
|
): string[] {
|
|
477
738
|
const patterns: string[] = [];
|
|
478
739
|
for (const route of routes) {
|
|
479
740
|
if (!route.path) continue;
|
|
480
|
-
|
|
481
|
-
route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
482
|
-
patterns.push(fullPath);
|
|
741
|
+
patterns.push(route.path);
|
|
483
742
|
if (route.children) {
|
|
484
743
|
patterns.push(
|
|
485
744
|
...flattenRoutePatterns(
|
|
486
745
|
route.children as Array<{ path?: string; children?: unknown[] }>,
|
|
487
|
-
fullPath,
|
|
488
746
|
),
|
|
489
747
|
);
|
|
490
748
|
}
|
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"}
|