@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 +107 -0
- package/package.json +34 -0
- package/src/compat.js +16 -0
- package/src/entrypoint.js +75 -0
- package/src/index.d.ts +44 -0
- package/src/index.js +157 -0
- package/src/preamble.js +24 -0
- package/src/routes.js +59 -0
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
|
+
}
|
package/src/preamble.js
ADDED
|
@@ -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
|
+
}
|