@pilvia/astro 0.1.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/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @pilvia/astro
2
+
3
+ The [Astro](https://astro.build) adapter for [Pilvia App](https://pilvia.com) —
4
+ EU-hosted, installable on your own iron. Prerendered pages and assets are
5
+ served by the host; on-demand pages, endpoints and Astro Actions run in an
6
+ isolate next to your data.
7
+
8
+ ```sh
9
+ bun add @pilvia/astro
10
+ ```
11
+
12
+ ```js
13
+ // astro.config.mjs
14
+ import { defineConfig } from "astro/config";
15
+ import pilvia from "@pilvia/astro";
16
+
17
+ export default defineConfig({
18
+ adapter: pilvia(),
19
+ });
20
+ ```
21
+
22
+ ```json
23
+ // pilvia.app.json — beside astro.config.mjs
24
+ {
25
+ "name": "my-site",
26
+ "auth": "public",
27
+ "build": "dist/pilvia.build.json",
28
+ "egress": { "allow": ["api.eu.mailgun.net"] }
29
+ }
30
+ ```
31
+
32
+ ```sh
33
+ astro build
34
+ pilvia app deploy
35
+ ```
36
+
37
+ That is the whole change. Switching back to another adapter is the same one
38
+ line in the other direction.
39
+
40
+ ## What the adapter does
41
+
42
+ - Builds the server half for a web-platform isolate: `webworker` target,
43
+ every dependency bundled (there is no `node_modules` at runtime), the
44
+ worker/browser exports of each package.
45
+ - Emits the platform contract, `export default { fetch(request, env) }`,
46
+ from `astro/app`. Nothing platform-specific is in your build.
47
+ - Writes **`dist/pilvia.build.json`** after the build: where the server entry
48
+ and the client assets are, and the routes Astro renders on demand —
49
+ derived from Astro's own route manifest. `pilvia app deploy` reads it via
50
+ `"build"`; nobody hand-writes a routes list.
51
+ - Surfaces the platform's capability object as **`Astro.locals.env`**
52
+ (`context.locals.env` in endpoints, middleware and Actions): `env.user`,
53
+ `env.memory`, `env.entities`, `env.broker`, … — whatever the app's manifest
54
+ grants. `Astro.clientAddress` is the real client behind the platform's load
55
+ balancer.
56
+ - Sets the image service to passthrough unless you configured one yourself:
57
+ sharp is native code and cannot run inside the isolate.
58
+
59
+ ## How requests are routed
60
+
61
+ The host routes **asset → declared route → fallback**. A prerendered page is
62
+ an asset the host serves (with strong ETags and a year of `immutable` on
63
+ hashed files); an on-demand page, an endpoint or `/_actions/*` is a declared
64
+ route the isolate answers; anything else gets your `404.html` without spinning
65
+ an isolate up. A fully prerendered site with no Actions deploys as a plain
66
+ static site — no isolate at all.
67
+
68
+ ## Outbound requests
69
+
70
+ The isolate has no ambient network. Name what your server code reaches in
71
+ `pilvia.app.json` → `"egress": { "allow": [...] }` (hosts, `*.example.com`
72
+ wildcards, optional `:port`). The platform intersects it with the box's
73
+ ceiling; a fetch to an unlisted host fails closed.
74
+
75
+ ## Limits, honestly
76
+
77
+ - **Astro sessions** need a driver; the platform's KV binding is not wired to
78
+ `astro:session` yet.
79
+ - **`astro:env` secrets** (`getSecret`) are not wired; read configuration from
80
+ `Astro.locals.env` or bake it in at build time.
81
+ - **Images**: passthrough at runtime. Prerendered pages keep build-time
82
+ optimisation only if you configure an image service that does it at build
83
+ time.
84
+ - **Node built-ins**: the isolate is a web-platform runtime, not Node. `Buffer`
85
+ is provided (the standard `buffer` polyfill) because sites reach for it
86
+ casually; other `node:*` modules are not.
87
+ - **Server islands** do not work yet: the engine's WebCrypto has no AES-GCM,
88
+ which Astro uses to encrypt island props. The adapter keeps Astro's eager
89
+ key import from spamming the log; a page that actually renders a server
90
+ island fails at that point with the engine's message.
91
+ - **Engine gaps** carried as guarded bundle-layer workarounds until the engine
92
+ catches up (`src/preamble.js`, `src/entrypoint.js`): no ICU (`Intl`,
93
+ `String.prototype.normalize`); `es-module-lexer` aliased to its pure-JS build
94
+ because the isolate has no `WebAssembly` global; the fetch-event Request is
95
+ rebuilt before Astro sees it (its immutable headers/body trip Actions'
96
+ `formData()`). None of these touch your code.
97
+
98
+ ## Development
99
+
100
+ ```sh
101
+ bun install
102
+ bun test # unit tests + a real `astro build` of test/fixture
103
+ ```
104
+
105
+ Part of [`pilvia/app`](https://github.com/pilvia/app) — see `docs/app-runtime.md`
106
+ there, "Server side" → Layer 2, for why this is an adapter of our own rather
107
+ than compatibility with another platform's build output.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@pilvia/astro",
3
+ "version": "0.1.0",
4
+ "description": "Astro adapter for Pilvia App — prerendered pages become host-served assets, on-demand routes and Actions run in the isolate, the routes list is derived from Astro's own manifest.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/pilvia/app.git",
10
+ "directory": "packages/astro"
11
+ },
12
+ "homepage": "https://pilvia.com",
13
+ "keywords": ["astro", "astro-adapter", "withastro", "pilvia"],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.d.ts",
17
+ "default": "./src/index.js"
18
+ },
19
+ "./entrypoint": "./src/entrypoint.js"
20
+ },
21
+ "files": ["src", "README.md"],
22
+ "scripts": {
23
+ "test": "bun test"
24
+ },
25
+ "peerDependencies": {
26
+ "astro": "^5.0.0"
27
+ },
28
+ "dependencies": {
29
+ "buffer": "^6.0.3"
30
+ },
31
+ "devDependencies": {
32
+ "astro": "^5.16.0"
33
+ }
34
+ }
package/src/compat.js ADDED
@@ -0,0 +1,16 @@
1
+ // Bundle-layer workaround for a gap in the ENGINE (StarlingMonkey as shipped
2
+ // by componentize-js) — not for any hosting platform's affordances. Guarded,
3
+ // so it is a no-op the day the engine grows the real thing. The load-order-
4
+ // sensitive shims (Intl, `process`) live in `preamble.js` instead, because an
5
+ // import cannot run before the chunks Astro's entry imports ahead of us.
6
+ //
7
+ // Node's Buffer. Site code reaches for it casually (`Buffer.from(s).toString(
8
+ // "base64")` for a Basic-auth header is the classic); this is the same
9
+ // polyfill every bundler ships for browsers, and it is only needed at request
10
+ // time, so import order does not matter. The trailing slash makes the bundler
11
+ // pick the npm package over a Node builtin of the same name.
12
+ import { Buffer } from "buffer/";
13
+
14
+ if (typeof globalThis.Buffer === "undefined") {
15
+ globalThis.Buffer = Buffer;
16
+ }
@@ -0,0 +1,75 @@
1
+ // The server entry Astro builds around: `astro/app` behind the platform
2
+ // contract, `export default { fetch(request, env) }`.
3
+ //
4
+ // By the time a request lands here the host has served every asset and
5
+ // prerendered page itself and gated the rest on the routes list this adapter
6
+ // derived — so the isolate sees only what Astro renders on demand. No asset
7
+ // binding, no cache API, no platform module.
8
+
9
+ import "./compat.js";
10
+ import { App } from "astro/app";
11
+
12
+ /**
13
+ * @param {import('astro').SSRManifest} manifest
14
+ */
15
+ export function createExports(manifest) {
16
+ // Astro imports its server-islands encryption key eagerly (`decodeKey` →
17
+ // `crypto.subtle.importKey(… "AES-GCM" …)`) and stores the PROMISE in
18
+ // `manifest.key`, awaiting it only when a server island renders. The
19
+ // engine's WebCrypto has no AES-GCM yet, so that promise rejects at module
20
+ // init and, left unhandled, StarlingMonkey reports it after EVERY request
21
+ // ("Unhandled promise rejections detected after handling incoming
22
+ // request"). Mark it handled here; the rejection itself stays put, so a
23
+ // site that does render a server island still fails loudly at that point
24
+ // with the engine's own message.
25
+ if (manifest?.key && typeof manifest.key.catch === "function") {
26
+ manifest.key.catch(() => {});
27
+ }
28
+ const app = new App(manifest);
29
+
30
+ /**
31
+ * @param {Request} request
32
+ * @param {unknown} env the platform's capability object (`env.memory`,
33
+ * `env.entities`, `env.user`, …) — surfaced to pages, endpoints,
34
+ * middleware and Actions as `Astro.locals.env` / `context.locals.env`.
35
+ */
36
+ async function fetch(request, env) {
37
+ const routeData = app.match(request);
38
+ // The host stamps the real client on `x-real-ip` (PROXY protocol behind
39
+ // the isle's load balancer; the TLS peer otherwise).
40
+ const clientAddress = request.headers.get("x-real-ip") ?? undefined;
41
+ const response = await app.render(workable(request), {
42
+ routeData,
43
+ locals: { env },
44
+ clientAddress,
45
+ });
46
+ for (const cookie of app.setCookieHeaders(response)) {
47
+ response.headers.append("set-cookie", cookie);
48
+ }
49
+ return response;
50
+ }
51
+
52
+ return { default: { fetch } };
53
+ }
54
+
55
+ /**
56
+ * Engine workaround, still needed on componentize-js 0.22 (verified
57
+ * 2026-08-27): the fetch-event Request in StarlingMonkey is not an ordinary
58
+ * Request — its Headers are immutable, and on ≤ 0.21 `Request.clone()` on it
59
+ * threw; on 0.22 clone works, but an Actions form POST handed the event
60
+ * request as-is still answers 415 UNSUPPORTED_MEDIA_TYPE where the same body
61
+ * on a rebuilt Request validates. Hand Astro an ordinary Request; it costs a
62
+ * header copy. Re-test on each engine upgrade and drop when it passes.
63
+ * @param {Request} request
64
+ */
65
+ function workable(request) {
66
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
67
+ return new Request(request.url, {
68
+ method: request.method,
69
+ headers: new Headers(request.headers),
70
+ body: hasBody ? request.body : undefined,
71
+ // A streamed body needs this on runtimes that check (spec'd, harmless
72
+ // where it is not).
73
+ duplex: "half",
74
+ });
75
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { AstroIntegration } from "astro";
2
+
3
+ /** No options yet — the adapter derives everything from the Astro build. */
4
+ export interface PilviaAdapterOptions {}
5
+
6
+ /**
7
+ * The platform's capability object, as handed to `fetch(request, env)` and
8
+ * surfaced by this adapter as `Astro.locals.env`. Only the capabilities the
9
+ * app's manifest grants are live; the rest throw when called (fail closed).
10
+ * The authoritative surface is `crates/tuike/src/guest/entry.js` in
11
+ * `pilvia/app`; this type is deliberately loose until it is generated from
12
+ * the WIT world.
13
+ */
14
+ export interface PilviaEnv {
15
+ /** Validated session claims of the signed-in user, or null when anonymous. */
16
+ user: Readonly<Record<string, unknown>> | null;
17
+ memory: {
18
+ search(query: string, opts?: Record<string, unknown>): Promise<unknown>;
19
+ list(opts?: Record<string, unknown>): Promise<unknown>;
20
+ get(id: string): Promise<unknown>;
21
+ write(content: string, opts?: Record<string, unknown>): Promise<unknown>;
22
+ };
23
+ entities: {
24
+ types(): Promise<unknown>;
25
+ list(filter?: Record<string, unknown>): Promise<unknown>;
26
+ get(ref: string | Record<string, unknown>): Promise<unknown>;
27
+ upsert(entity: Record<string, unknown>): Promise<unknown>;
28
+ upsertBatch(items: unknown[], opts?: Record<string, unknown>): Promise<unknown>;
29
+ link(from: string, rel: string, to: string): Promise<unknown>;
30
+ };
31
+ broker: { call(req: Record<string, unknown>): Promise<unknown> };
32
+ [capability: string]: unknown;
33
+ }
34
+
35
+ declare global {
36
+ namespace App {
37
+ interface Locals {
38
+ /** The Pilvia App capability object for this request. */
39
+ env: PilviaEnv;
40
+ }
41
+ }
42
+ }
43
+
44
+ export default function pilvia(options?: PilviaAdapterOptions): AstroIntegration;
package/src/index.js ADDED
@@ -0,0 +1,157 @@
1
+ // @pilvia/astro — the Astro adapter for Pilvia App.
2
+ //
3
+ // What it does, and deliberately no more:
4
+ // * tells Astro to build for a server (`astro/app` + our entrypoint) with
5
+ // the web-platform half of the dependency graph (`webworker` target,
6
+ // everything bundled — the isolate has no node_modules);
7
+ // * points the image service at passthrough (sharp cannot run inside the
8
+ // isolate; prerendered pages are optimised at build time only if you
9
+ // configure a service that does so yourself);
10
+ // * after the build, writes `dist/pilvia.build.json`: where the server
11
+ // entry and the client assets are, and the routes Astro renders on
12
+ // demand — derived from Astro's own route manifest. `pilvia app deploy`
13
+ // reads it via `"build"` in `pilvia.app.json`.
14
+ //
15
+ // The host is the front door: it serves prerendered pages and assets itself
16
+ // and hands the isolate only the declared routes, so this adapter needs no
17
+ // asset binding, no cache API and no platform module — the platform
18
+ // contract is `export default { fetch(request, env) }` and nothing else
19
+ // (charter: docs/app-runtime.md → "Server side" → Layer 2).
20
+
21
+ import { writeFile } from "node:fs/promises";
22
+ import { createRequire } from "node:module";
23
+ import { fileURLToPath } from "node:url";
24
+ import { relative } from "node:path";
25
+ import { passthroughImageService } from "astro/config";
26
+ import { PREAMBLE } from "./preamble.js";
27
+ import { hostRoutes } from "./routes.js";
28
+
29
+ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
30
+
31
+ const SHARP = "astro/assets/services/sharp";
32
+
33
+ /**
34
+ * @param {import('./index.js').PilviaAdapterOptions} [options]
35
+ * @returns {import('astro').AstroIntegration}
36
+ */
37
+ export default function pilvia(options = {}) {
38
+ /** @type {import('astro').AstroConfig} */
39
+ let config;
40
+ /** @type {import('astro').IntegrationResolvedRoute[]} */
41
+ let routes = [];
42
+ let buildOutput = "server";
43
+
44
+ return {
45
+ name: "@pilvia/astro",
46
+ hooks: {
47
+ "astro:config:setup": ({ config: cfg, updateConfig, logger }) => {
48
+ const update = {};
49
+ // Astro's default image service is sharp — native code, which the
50
+ // isolate cannot load. Swap it for passthrough unless the site chose
51
+ // something else on purpose.
52
+ if (!cfg.image?.service?.entrypoint || cfg.image.service.entrypoint === SHARP) {
53
+ update.image = { service: passthroughImageService() };
54
+ logger.info("image service: passthrough (sharp cannot run in the isolate)");
55
+ }
56
+ updateConfig(update);
57
+ },
58
+
59
+ "astro:config:done": ({ setAdapter, config: cfg, buildOutput: out }) => {
60
+ config = cfg;
61
+ buildOutput = out;
62
+ setAdapter({
63
+ name: "@pilvia/astro",
64
+ serverEntrypoint: "@pilvia/astro/entrypoint",
65
+ exports: ["default"],
66
+ adapterFeatures: {
67
+ edgeMiddleware: false,
68
+ buildOutput: out,
69
+ },
70
+ supportedAstroFeatures: {
71
+ staticOutput: "stable",
72
+ hybridOutput: "stable",
73
+ serverOutput: "stable",
74
+ i18nDomains: "unsupported",
75
+ // `astro:env` secrets: the isolate has no process env; the
76
+ // platform's `env` is `Astro.locals.env`. Wire getSecret to it
77
+ // when a site needs it.
78
+ envGetSecret: "unsupported",
79
+ sharpImageService: {
80
+ support: "limited",
81
+ message:
82
+ "sharp cannot run inside the Pilvia isolate; @pilvia/astro serves images as built (passthrough).",
83
+ suppress: "default",
84
+ },
85
+ },
86
+ });
87
+ },
88
+
89
+ "astro:routes:resolved": ({ routes: resolved }) => {
90
+ routes = resolved;
91
+ },
92
+
93
+ "astro:build:setup": ({ vite, target }) => {
94
+ if (target !== "server") return;
95
+ // The server bundle runs in a web-platform isolate: resolve the
96
+ // worker/browser half of every package and bundle all of it — there
97
+ // is no node_modules at runtime.
98
+ vite.ssr ||= {};
99
+ vite.ssr.target = "webworker";
100
+ vite.ssr.noExternal = true;
101
+ vite.ssr.resolve ||= {};
102
+ vite.ssr.resolve.conditions = [
103
+ "worker",
104
+ "browser",
105
+ "module",
106
+ "development|production",
107
+ ...(vite.ssr.resolve.conditions ?? []),
108
+ ];
109
+ // es-module-lexer's default entry instantiates a WebAssembly module at
110
+ // load; the isolate has no nested WebAssembly. Its `/js` entry is the
111
+ // same lexer compiled to plain JS — Astro only needs it for imports.
112
+ vite.resolve ||= {};
113
+ vite.resolve.alias ||= {};
114
+ const alias = { find: "es-module-lexer", replacement: "es-module-lexer/js" };
115
+ if (Array.isArray(vite.resolve.alias)) {
116
+ vite.resolve.alias.push(alias);
117
+ } else {
118
+ vite.resolve.alias[alias.find] = alias.replacement;
119
+ }
120
+ vite.build ||= {};
121
+ vite.build.rollupOptions ||= {};
122
+ vite.build.rollupOptions.output ||= {};
123
+ // Engine-gap workarounds that must run before any module body — see
124
+ // preamble.js. Rollup prepends a banner to every chunk.
125
+ const banner = vite.build.rollupOptions.output.banner;
126
+ vite.build.rollupOptions.output.banner =
127
+ typeof banner === "function"
128
+ ? async (chunk) => `${PREAMBLE}\n${await banner(chunk)}`
129
+ : `${PREAMBLE}\n${banner ?? ""}`;
130
+ vite.define = { "process.env": "process.env", ...vite.define };
131
+ },
132
+
133
+ "astro:build:done": async ({ logger }) => {
134
+ const outDir = config.outDir;
135
+ const rel = (url) => relative(fileURLToPath(outDir), fileURLToPath(url)).split("\\").join("/");
136
+ const desc = {
137
+ adapter: "@pilvia/astro",
138
+ version: VERSION,
139
+ assets: rel(config.build.client) || ".",
140
+ };
141
+ if (buildOutput === "server") {
142
+ desc.entry = rel(new URL(config.build.serverEntry, config.build.server));
143
+ desc.routes = hostRoutes(routes);
144
+ } else {
145
+ desc.routes = [];
146
+ }
147
+ await writeFile(new URL("./pilvia.build.json", outDir), `${JSON.stringify(desc, null, 2)}\n`);
148
+ logger.info(
149
+ desc.entry
150
+ ? `pilvia.build.json: ${desc.routes.length} on-demand route(s) → the isolate; everything else is a host-served asset`
151
+ : "pilvia.build.json: fully static — the host serves everything, no isolate",
152
+ );
153
+ void options;
154
+ },
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,24 @@
1
+ // Text prepended to EVERY server chunk (Rollup `output.banner`), so it runs
2
+ // before any module body — an `import` of a compat module cannot promise
3
+ // that, because ESM evaluates imported chunks before the importer's own code
4
+ // and Astro's generated entry imports its renderers and manifest ahead of
5
+ // the adapter entrypoint.
6
+ //
7
+ // These are workarounds for gaps in the ENGINE (StarlingMonkey as shipped by
8
+ // componentize-js), not for any hosting platform's affordances. Each is
9
+ // guarded, so it is a no-op wherever the real thing exists — under bun/node
10
+ // in tests, and in the engine the day it grows ICU. Charter: "anything
11
+ // shim-shaped is scaffolding to be replaced by the engine's own surface,
12
+ // never grown."
13
+ export const PREAMBLE = [
14
+ // Astro's own emitted `import.meta.env` guard reads `process.env` at load.
15
+ "globalThis.process ??= {}; globalThis.process.env ??= {};",
16
+ // No ICU in the engine build. Astro's logger builds an Intl.DateTimeFormat
17
+ // at module scope for log timestamps, and its router calls `normalize` on
18
+ // route params. Identity/ISO stand-ins cover exactly those two uses.
19
+ 'if (typeof globalThis.Intl === "undefined") { globalThis.Intl = {',
20
+ " DateTimeFormat: class { format(d = new Date()) { return new Date(d).toISOString().slice(11, 19); } resolvedOptions() { return {}; } },",
21
+ " NumberFormat: class { format(n) { return String(n); } resolvedOptions() { return {}; } },",
22
+ "}; }",
23
+ 'if (typeof String.prototype.normalize !== "function") { Object.defineProperty(String.prototype, "normalize", { value() { return String(this); }, writable: true, configurable: true }); }',
24
+ ].join("\n");
package/src/routes.js ADDED
@@ -0,0 +1,59 @@
1
+ // Astro's route manifest → the host's `routes` list.
2
+ //
3
+ // The host routes asset → declared route → fallback (charter: "The hosting
4
+ // product"). Astro's own prerender/on-demand split maps onto that one-to-one:
5
+ // a prerendered page is an asset the host serves, an on-demand page, endpoint
6
+ // or the `/_actions/*` RPC route is a declared route the isolate answers.
7
+ // Deriving the list here means nobody hand-writes it and it cannot drift
8
+ // from the build.
9
+
10
+ /**
11
+ * One Astro route pattern (`/blog/[slug]`, `/_actions/[...path]`) to the
12
+ * host's grammar: `:param` takes exactly one segment, a trailing `*` takes
13
+ * the rest of the path (Astro's `[...rest]`, zero or more segments).
14
+ *
15
+ * A segment that mixes literal text and a param (`[lang]-[slug]`,
16
+ * `page-[n]`) still occupies exactly one path segment, so it becomes a
17
+ * single `:param` — the host matches by segment count, Astro then matches
18
+ * the real pattern inside the isolate. A rest segment anywhere but last is
19
+ * a bug in the calling site, not something to guess around.
20
+ * @param {string} pattern
21
+ * @returns {string}
22
+ */
23
+ export function toHostPattern(pattern) {
24
+ const segs = pattern.split("/").filter(Boolean);
25
+ const out = segs.map((seg, i) => {
26
+ if (/^\[\.\.\.[^\]]+\]$/.test(seg)) {
27
+ if (i !== segs.length - 1) {
28
+ throw new Error(`@pilvia/astro: rest segment must be last in ${pattern}`);
29
+ }
30
+ return "*";
31
+ }
32
+ if (seg.includes("[")) {
33
+ const name = seg.replace(/[[\]]/g, "").replace(/\.\.\./g, "").replace(/[^A-Za-z0-9_-]/g, "_");
34
+ return `:${name || "param"}`;
35
+ }
36
+ return seg;
37
+ });
38
+ return `/${out.join("/")}`;
39
+ }
40
+
41
+ /**
42
+ * The declared routes for a build: every route Astro renders on demand,
43
+ * whatever its origin (a page, an endpoint, Astro's own `/_actions`,
44
+ * `/_image`, `/_server-islands`, a server-side redirect). Prerendered routes
45
+ * are assets and stay out. Literals first, then by parameter count, so the
46
+ * list reads the way the host resolves it.
47
+ * @param {ReadonlyArray<{ pattern: string, isPrerendered: boolean, type: string }>} routes
48
+ * @returns {string[]}
49
+ */
50
+ export function hostRoutes(routes) {
51
+ const seen = new Set();
52
+ for (const r of routes) {
53
+ if (r.isPrerendered) continue;
54
+ if (r.type === "fallback") continue; // i18n fallback stubs: Astro serves them from the real route
55
+ seen.add(toHostPattern(r.pattern));
56
+ }
57
+ const params = (p) => p.split("/").filter((s) => s.startsWith(":") || s === "*").length;
58
+ return [...seen].sort((a, b) => params(a) - params(b) || (a < b ? -1 : a > b ? 1 : 0));
59
+ }