mirror of
https://github.com/aphrody-labs/aphrody-m3-bun.git
synced 2026-10-13 19:09:55 +00:00
chore: migrate package sources from Aphrody
This commit is contained in:
1 parent
676d797af9
commit
edef8cdc24
104 files changed
+12776
-2
No files matched your search
@@ -1,2 +1,108 @@
|
|||||||
# aphrody-m3-bun
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||||
Aphrody package @aphrody/m3-bun
|
|
||||||
|
# `@aphrody/m3-bun`
|
||||||
|
|
||||||
|
The one Bun-native tool for M3 apps: a single Bun plugin and the `m3-bun` CLI. It builds, minifies, serves with HMR,
|
||||||
|
compiles to one executable, packages and generates deploy configs. One Bun file server serves the UI files and the API
|
||||||
|
from the same process. Full guide: `docs/guides/M3-BUN.md`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
m3-bun dev # HMR dev server: UI and API in one process (bunfig [serve.static] plugins)
|
||||||
|
m3-bun build [--minify none|basic|full] [--graph[=<registry>]]
|
||||||
|
m3-bun graph [file] # recorded builds (bun:graph), or what depends on a file
|
||||||
|
m3-bun serve # production server of the build directory
|
||||||
|
m3-bun compile [--target linux-x64-musl|windows-x64|darwin-arm64|...]
|
||||||
|
m3-bun package [--rootfs] [--deploy]
|
||||||
|
m3-bun deploy docker systemd nginx vercel fly cloudflare coolify vscode windows linux-image # or: all
|
||||||
|
m3-bun smoke # run the executable (yolo runtime when installed), GET /, /healthz, /api/m3/info
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the Aphrody checkout the same commands run as `yolo m3 bun <command>`.
|
||||||
|
|
||||||
|
## The plugin
|
||||||
|
|
||||||
|
`@aphrody/m3-bun/plugin` is `@aphrody/m3`'s plugin (Tailwind v4, Sass, `m3:*` token sheets, `?url`) plus what a
|
||||||
|
standalone checkout needs: the token sheets from `@aphrody/m3-tokens` when the Rust library is absent, the font sheets from
|
||||||
|
`@aphrody/m3-fonts`, `m3:icons/[style/]name[?fill=1&wght=300]` (Material Symbols SVG markup from the forked catalogue, inlined
|
||||||
|
at build time) and `m3:optional/<specifier>` (a module that may not be installed: its namespace and `resolved: true`, or
|
||||||
|
`null` and `resolved: false`).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# bunfig.toml
|
||||||
|
[serve.static]
|
||||||
|
plugins = ["@aphrody/m3-bun/plugin"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// m3.config.ts
|
||||||
|
export default defineM3Config({ entry: "src/index.html", theme: { seed: "#6750a4" } });
|
||||||
|
export const bun = defineM3Bun({ api: "src/api/index.ts", port: 3000, compile: { bytecode: true } });
|
||||||
|
```
|
||||||
|
|
||||||
|
The fork's Bun plugins compose behind options of `m3Bun` (`src/plugins.ts`): `yaml`, `svelte` (on by default), `mdx`
|
||||||
|
(`@aphrody/bun-mdx-rs`, native `onBeforeParse`), `n2b` and `oxc`. Each one is imported on the first module its hooks
|
||||||
|
match. The m3 packages are modules too (`src/plugins-m3.ts`): `m3:icon/<style>/<name>` (`@aphrody/m3-icons`),
|
||||||
|
`m3:theme/{tokens,tailwind,spaceui}.css` (`@aphrody/m3-theme`) and `m3:assets/<path>[?url]` (`@aphrody/assets`).
|
||||||
|
|
||||||
|
## Build graph (`bun:graph`)
|
||||||
|
|
||||||
|
`m3-bun build --graph` (or `bun: { graph: true }` in `m3.config.ts`) records the build in the `bun:graph` SQLite registry of
|
||||||
|
the Aphrody fork of Bun, `.m3-bun/graph.sqlite` by default: the app is a repository, the build a run with its duration
|
||||||
|
sample and one artifact (path, SHA-256, bytes) per output file, and the `Bun.build` metafile a graph snapshot. Its nodes
|
||||||
|
are the inputs (virtual `m3:` modules included), the outputs and the external imports; its edges the imports, keyed by
|
||||||
|
import kind, and `bundled-into` from an input to each output it contributed to. `m3-bun graph` prints the `bun:graphx`
|
||||||
|
markdown report; `m3-bun graph src/main.ts` lists the modules that import the file, transitively, and the outputs it went
|
||||||
|
into. Runtimes without `bun:graph` fail with an explicit error.
|
||||||
|
|
||||||
|
## One process, UI and API
|
||||||
|
|
||||||
|
`src/api` exports Bun routes (`defineM3Api`); `Bun.serve` runs them natively next to the file server (cache headers,
|
||||||
|
ETag, brotli and gzip, SPA fallback, `/healthz`, `/api/m3/info`, security headers, traversal guard). `routesToFetch` turns the
|
||||||
|
same routes into one `fetch` handler for Cloudflare Workers and Vercel functions.
|
||||||
|
|
||||||
|
The server's optional parts load on first use (`src/server-*.ts`), on Bun APIs only:
|
||||||
|
|
||||||
|
| Module | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| HTML | `HTMLRewriter`: CSP nonce on every script, and the request's theme |
|
||||||
|
| Markdown | `Bun.markdown` pages |
|
||||||
|
| Crawl | `sitemap.xml`, `robots.txt` and `llms.txt` |
|
||||||
|
| Realtime | WebSocket topics |
|
||||||
|
| Data | `bun:sqlite` with migrations |
|
||||||
|
| Secrets | `Bun.secrets` |
|
||||||
|
| Auth | `Bun.password` and sessions |
|
||||||
|
| Cache | `Bun.RedisClient` |
|
||||||
|
| MCP | `@aphrody/m3-mcp` tools |
|
||||||
|
| AG-UI | `@aphrody/m3-ai` |
|
||||||
|
| Next | `@aphrody/next-bun` behind a proxy |
|
||||||
|
| m3 routes | `@aphrody/m3/server` routes |
|
||||||
|
|
||||||
|
`m3-bun dev --inspect [--inspector-ui]` runs the dev server in a child Bun with its inspector on, relays its console and
|
||||||
|
exceptions, and serves the WebKit Web Inspector (`@aphrody/web-inspector-bun`). API load and handler errors go to Bun's
|
||||||
|
error overlay.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
`m3-bun lint` (eslint-plugin-m3 through oxlint), `migrate` (m3-codemods, `--next` for m3-next-migrate), `create`
|
||||||
|
(scaffold), `design` (m3-design), `og` (canvas Open Graph image) and `lib` (m3-config library build). Each tool package
|
||||||
|
loads on first use (`src/tools-*.ts`). If a package is missing, the error names it.
|
||||||
|
|
||||||
|
Deploy targets also include `tauri`, `webos`, `lambda`, `os-themes`, `release`, `next` and `windows-inspect`.
|
||||||
|
|
||||||
|
## Compile: bytecode and the limits
|
||||||
|
|
||||||
|
`m3-bun compile` embeds the built files and the API in a generated entry and links it with the Bun runtime. It passes
|
||||||
|
`bytecode: true` to `Bun.build` (with `minify` as configured and `sourcemap: "none"`): JavaScriptCore bytecode is stored in
|
||||||
|
the executable, so start-up skips parsing. Measured on the fixture app (medians, same host, interleaved runs):
|
||||||
|
`--version` 4.3 ms instead of 7.5 ms, first `/healthz` 5.8 ms instead of 9.3 ms; the executable grows by about 1.5 MB.
|
||||||
|
Bytecode output is CommonJS, so the generated entry has no top-level await. `compile: { bytecode: false }` turns it off.
|
||||||
|
|
||||||
|
The executable is not static: glibc targets link libc, libm, libdl, libpthread and the loader, musl targets link musl and
|
||||||
|
libstdc++. Images carry those libraries (`m3-bun package --rootfs`, the `linux-image` target); the `docker` target
|
||||||
|
compiles and runs on `ghcr.io/aphrody-labs/alpine:3.24-runtime` (musl, libstdc++, CA certificates, tzdata). Windows
|
||||||
|
metadata (`compile.windows`) is applied only when compiling on Windows; elsewhere the report says so.
|
||||||
|
|
||||||
|
`m3 compile` of `@aphrody/m3` runs this compiler (an optional peer): compiling an HTML entry directly yields an HTML
|
||||||
|
file, not an executable.
|
||||||
|
|
||||||
|
Deploy targets are files; nothing here applies, pushes or reloads anything.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { runCli } from "../src/cli.ts";
|
||||||
|
|
||||||
|
process.exitCode = await runCli(process.argv.slice(2));
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Ambient types of the `m3:*` modules the @aphrody/m3-bun plugin serves. Reference it from an app:
|
||||||
|
// /// <reference types="@aphrody/m3-bun/client" />
|
||||||
|
|
||||||
|
/** Material Symbols SVG markup: `m3:icons/home`, `m3:icons/rounded/home?fill=1&wght=300`. */
|
||||||
|
declare module "m3:icons/*" {
|
||||||
|
const svg: string;
|
||||||
|
export default svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A module that may be absent: the namespace and `resolved: true`, or `null` and `resolved: false`. */
|
||||||
|
declare module "m3:optional/*" {
|
||||||
|
const namespace: Record<string, unknown> | null;
|
||||||
|
export default namespace;
|
||||||
|
export const resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "m3:theme.css";
|
||||||
|
declare module "m3:tokens.css";
|
||||||
|
declare module "m3:scheme.css";
|
||||||
|
declare module "m3:fonts.css";
|
||||||
|
declare module "m3:fonts-jp.css";
|
||||||
|
declare module "m3:fonts-symbols.css";
|
||||||
|
|
||||||
|
/** Bun file-loader asset imports, including embedded font assets in compiled apps. */
|
||||||
|
declare module "*.woff2" {
|
||||||
|
const path: string;
|
||||||
|
export default path;
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"name": "@aphrody/m3-bun",
|
||||||
|
"version": "1.4.4",
|
||||||
|
"description": "The one Bun-native tool for M3 apps: a single Bun plugin and the m3-bun CLI that build, minify, serve (HMR), compile to one executable, package and generate deploy configs (Docker, systemd, nginx, Vercel, Fly.io, Cloudflare, Coolify, VS Code, Windows, minimal Linux image). One Bun file server serves the UI and the API from one process.",
|
||||||
|
"keywords": [
|
||||||
|
"bun",
|
||||||
|
"compile",
|
||||||
|
"deploy",
|
||||||
|
"material-3"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"author": "aphrody-code",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/aphrody-labs/aphrody-m3.git",
|
||||||
|
"directory": "packages/m3-bun"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"m3-bun": "./bin/m3-bun.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"bin",
|
||||||
|
"client.d.ts"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./plugin": "./src/plugin.ts",
|
||||||
|
"./server": "./src/server.ts",
|
||||||
|
"./api": "./src/api.ts",
|
||||||
|
"./config": "./src/config.ts",
|
||||||
|
"./targets": "./src/targets/index.ts",
|
||||||
|
"./graph": "./src/graph.ts",
|
||||||
|
"./runtime": "./src/runtime.ts",
|
||||||
|
"./plugins": "./src/plugins.ts",
|
||||||
|
"./plugins-m3": "./src/plugins-m3.ts",
|
||||||
|
"./db": "./src/server-db.ts",
|
||||||
|
"./auth": "./src/server-auth.ts",
|
||||||
|
"./secrets": "./src/server-secrets.ts",
|
||||||
|
"./cache": "./src/server-cache.ts",
|
||||||
|
"./mcp": "./src/server-mcp.ts",
|
||||||
|
"./realtime": "./src/server-realtime.ts",
|
||||||
|
"./markdown": "./src/server-markdown.ts",
|
||||||
|
"./ag-ui": "./src/server-agui.ts",
|
||||||
|
"./next": "./src/server-next.ts",
|
||||||
|
"./m3-routes": "./src/server-m3.ts",
|
||||||
|
"./dev-inspect": "./src/dev-inspect.ts",
|
||||||
|
"./dev-error": "./src/dev-error.ts",
|
||||||
|
"./theme-css": "./src/theme-css.ts",
|
||||||
|
"./tools": "./src/tools-load.ts",
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./client": {
|
||||||
|
"types": "./client.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "bun check -p tsconfig.json --noEmit --customConditions bun",
|
||||||
|
"test": "bun test",
|
||||||
|
"m3-bun": "bun bin/m3-bun.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aphrody/assets": "workspace:*",
|
||||||
|
"@aphrody/eslint-plugin-m3": "workspace:*",
|
||||||
|
"@aphrody/identity": "workspace:*",
|
||||||
|
"@aphrody/m3": "workspace:*",
|
||||||
|
"@aphrody/m3-codemods": "workspace:*",
|
||||||
|
"@aphrody/m3-config": "workspace:*",
|
||||||
|
"@aphrody/m3-design": "workspace:*",
|
||||||
|
"@aphrody/m3-fonts": "workspace:*",
|
||||||
|
"@aphrody/m3-icons": "workspace:*",
|
||||||
|
"@aphrody/m3-os-themes": "workspace:*",
|
||||||
|
"@aphrody/m3-theme": "workspace:*",
|
||||||
|
"@aphrody/m3-tokens": "workspace:*",
|
||||||
|
"@aphrody/material-design-icons": "workspace:*",
|
||||||
|
"@aphrody/scaffold": "workspace:*",
|
||||||
|
"@aphrody/web-source": "workspace:*",
|
||||||
|
"@aphrody/web-to-tauri": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@aphrody/bun-debug-adapter-protocol": "0.0.1-aphrody.1",
|
||||||
|
"@aphrody/bun-inspector-protocol": "0.0.2-aphrody.1",
|
||||||
|
"@aphrody/bun-mdx-rs": "1.4.3-aphrody.1",
|
||||||
|
"@aphrody/bun-plugin-n2b": "0.7.0",
|
||||||
|
"@aphrody/bun-plugin-oxc": "0.2.0",
|
||||||
|
"@aphrody/bun-plugin-svelte": "0.0.6-aphrody.3",
|
||||||
|
"@aphrody/bun-plugin-yaml": "0.0.1-aphrody.3",
|
||||||
|
"@aphrody/bun-webview-page": "0.1.1-aphrody.3",
|
||||||
|
"@aphrody/canvas": "workspace:*",
|
||||||
|
"@aphrody/m3-ai": "workspace:*",
|
||||||
|
"@aphrody/m3-mcp": "workspace:*",
|
||||||
|
"@aphrody/m3-next": "workspace:*",
|
||||||
|
"@aphrody/m3-next-migrate": "workspace:*",
|
||||||
|
"@aphrody/next-bun": "0.2.0-aphrody.2",
|
||||||
|
"@aphrody/web-inspector-bun": "1.4.3-aphrody.1",
|
||||||
|
"oxlint": "1.87.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aphrody/bun-debug-adapter-protocol": "0.0.1-aphrody.1",
|
||||||
|
"@aphrody/bun-inspector-protocol": "0.0.2-aphrody.1",
|
||||||
|
"@aphrody/bun-mdx-rs": "1.4.3-aphrody.1",
|
||||||
|
"@aphrody/bun-plugin-n2b": "0.7.0",
|
||||||
|
"@aphrody/bun-plugin-oxc": "0.2.0",
|
||||||
|
"@aphrody/bun-plugin-svelte": "0.0.6-aphrody.3",
|
||||||
|
"@aphrody/bun-plugin-yaml": "0.0.1-aphrody.3",
|
||||||
|
"@aphrody/bun-webview-page": "0.1.1-aphrody.3",
|
||||||
|
"@aphrody/canvas": "*",
|
||||||
|
"@aphrody/m3-ai": "*",
|
||||||
|
"@aphrody/m3-mcp": "*",
|
||||||
|
"@aphrody/m3-next": "*",
|
||||||
|
"@aphrody/m3-next-migrate": "*",
|
||||||
|
"@aphrody/next-bun": "0.2.0-aphrody.2",
|
||||||
|
"@aphrody/web-inspector-bun": "1.4.3-aphrody.1",
|
||||||
|
"oxlint": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@aphrody/bun-debug-adapter-protocol": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-inspector-protocol": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-mdx-rs": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-plugin-n2b": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-plugin-oxc": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-plugin-svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-plugin-yaml": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/bun-webview-page": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/canvas": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/m3-ai": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/m3-mcp": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/m3-next": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/m3-next-migrate": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/next-bun": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aphrody/web-inspector-bun": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"oxlint": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The API of an app, written once as Bun routes. `m3-bun` serves it natively next to the UI in one process;
|
||||||
|
// `routesToFetch` turns the same routes into one `fetch(Request)` handler for hosts that are not Bun
|
||||||
|
// (Cloudflare Workers, Vercel functions, tests).
|
||||||
|
//
|
||||||
|
// export default defineM3Api({
|
||||||
|
// "/api/hello/:name": (req) => Response.json({ hello: req.params.name }),
|
||||||
|
// "/api/items": { GET: () => Response.json([]), POST: async (req) => Response.json(await req.json(), { status: 201 }) },
|
||||||
|
// });
|
||||||
|
|
||||||
|
/** A route request: path params, and cookies (`request.cookies` of Bun routes, parsed from `Cookie` elsewhere). */
|
||||||
|
export type M3Request = Request & { params: Record<string, string>; cookies: Bun.CookieMap };
|
||||||
|
export type M3Handler = (request: M3Request) => Response | Promise<Response>;
|
||||||
|
export type M3Methods = Partial<Record<"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS", M3Handler>>;
|
||||||
|
export type M3Route = M3Handler | M3Methods | Response;
|
||||||
|
export type M3Routes = Record<string, M3Route>;
|
||||||
|
|
||||||
|
/** An API module's default export: routes only, or routes plus a fallback `fetch` that may return undefined to pass. */
|
||||||
|
export type M3ApiModule =
|
||||||
|
| M3Routes
|
||||||
|
| {
|
||||||
|
routes?: M3Routes;
|
||||||
|
fetch?: (request: Request) => Response | undefined | Promise<Response | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Identity helper that types an API module. */
|
||||||
|
export function defineM3Api<T extends M3ApiModule>(api: T): T {
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fallback = (request: Request) => Response | undefined | Promise<Response | undefined>;
|
||||||
|
|
||||||
|
/** Split a module's default export into routes and the optional fallback (route keys always start with `/`). */
|
||||||
|
export function normalizeApi(api: M3ApiModule | undefined): { routes: M3Routes; fetch?: Fallback } {
|
||||||
|
if (!api) return { routes: {} };
|
||||||
|
if ("routes" in api || "fetch" in api) {
|
||||||
|
const module = api as { routes?: M3Routes; fetch?: Fallback };
|
||||||
|
return { routes: module.routes ?? {}, ...(module.fetch ? { fetch: module.fetch } : {}) };
|
||||||
|
}
|
||||||
|
return { routes: api as M3Routes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The request cookies: Bun routes carry `request.cookies`; other hosts get one parsed from the `Cookie` header. */
|
||||||
|
export function requestCookies(request: Request): Bun.CookieMap {
|
||||||
|
const native = (request as Partial<M3Request>).cookies;
|
||||||
|
return native instanceof Bun.CookieMap ? native : new Bun.CookieMap(request.headers.get("cookie") ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `response` plus one `Set-Cookie` header per change made to `cookies` (what `Bun.serve` does for its routes). */
|
||||||
|
export function withCookies(response: Response, cookies: Bun.CookieMap): Response {
|
||||||
|
const set = cookies.toSetCookieHeaders();
|
||||||
|
if (set.length === 0) return response;
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
for (const cookie of set) headers.append("Set-Cookie", cookie);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(handler: M3Handler, request: M3Request, cookies?: Bun.CookieMap): Promise<Response> {
|
||||||
|
let response = await handler(request);
|
||||||
|
if (cookies) response = withCookies(response, cookies);
|
||||||
|
if (request.method !== "HEAD") return response;
|
||||||
|
await response.body?.cancel();
|
||||||
|
return new Response(null, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Compiled {
|
||||||
|
segments: string[];
|
||||||
|
wildcard: boolean;
|
||||||
|
route: M3Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compile = (pattern: string, route: M3Route): Compiled => {
|
||||||
|
const wildcard = pattern.endsWith("/*");
|
||||||
|
const base = wildcard ? pattern.slice(0, -2) : pattern;
|
||||||
|
return { segments: base.split("/").filter(Boolean), wildcard, route };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Match `path` against a compiled pattern; returns the params or undefined. Static segments outrank params. */
|
||||||
|
function match(path: string, route: Compiled): Record<string, string> | undefined {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
if (route.wildcard ? parts.length < route.segments.length : parts.length !== route.segments.length) return undefined;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < route.segments.length; i++) {
|
||||||
|
const segment = route.segments[i]!;
|
||||||
|
if (segment.startsWith(":")) params[segment.slice(1)] = decodeURIComponent(parts[i]!);
|
||||||
|
else if (segment !== parts[i]) return undefined;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRoutes(a: Compiled, b: Compiled): number {
|
||||||
|
for (let i = 0; i < Math.min(a.segments.length, b.segments.length); i++) {
|
||||||
|
const difference = Number(a.segments[i]!.startsWith(":")) - Number(b.segments[i]!.startsWith(":"));
|
||||||
|
if (difference) return difference;
|
||||||
|
}
|
||||||
|
return b.segments.length - a.segments.length || Number(a.wildcard) - Number(b.wildcard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `fetch` handler for Bun-style routes: `:param` segments, trailing `/*`, per-method objects and static
|
||||||
|
* `Response` values. Unmatched requests resolve to `undefined` so a host can fall through to its assets.
|
||||||
|
*/
|
||||||
|
export function routesToFetch(routes: M3Routes): (request: Request) => Promise<Response | undefined> {
|
||||||
|
const compiled = Object.entries(routes)
|
||||||
|
.map(([pattern, route]) => compile(pattern, route))
|
||||||
|
.toSorted(compareRoutes);
|
||||||
|
return async request => {
|
||||||
|
const { pathname } = new URL(request.url);
|
||||||
|
for (const entry of compiled) {
|
||||||
|
let params: Record<string, string> | undefined;
|
||||||
|
try {
|
||||||
|
params = match(pathname, entry);
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad Request", { status: 400 });
|
||||||
|
}
|
||||||
|
if (!params) continue;
|
||||||
|
const route = entry.route;
|
||||||
|
const handler =
|
||||||
|
route instanceof Response
|
||||||
|
? () => route.clone()
|
||||||
|
: typeof route === "function"
|
||||||
|
? route
|
||||||
|
: route[request.method as keyof M3Methods];
|
||||||
|
if (!handler) {
|
||||||
|
const allow = typeof route === "object" && !(route instanceof Response) ? Object.keys(route).join(", ") : "";
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: allow ? { Allow: allow } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Outside Bun routes, `request.cookies` is emulated and its changes applied to the response, as Bun does.
|
||||||
|
const cookies =
|
||||||
|
(request as Partial<M3Request>).cookies instanceof Bun.CookieMap ? undefined : requestCookies(request);
|
||||||
|
const withParams = Object.assign(request, cookies ? { params, cookies } : { params });
|
||||||
|
return respond(handler, withParams as M3Request, cookies);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||||
|
|
||||||
|
export interface M3CsrfOptions {
|
||||||
|
/** Signing secret (see `m3Secret` for one kept in the OS credential store). */
|
||||||
|
secret: string;
|
||||||
|
/** Request header carrying the token (default `x-csrf-token`). */
|
||||||
|
header?: string;
|
||||||
|
/** Token lifetime in milliseconds (default Bun's, 24 hours). */
|
||||||
|
maxAge?: number;
|
||||||
|
/** Principal the tokens are bound to (a session id): a token only verifies for the session it was issued to. */
|
||||||
|
sessionOf?: (request: M3Request) => string | undefined | Promise<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3Csrf {
|
||||||
|
/** A new token (`Bun.CSRF.generate`), bound to `sessionId` when given. */
|
||||||
|
token(sessionId?: string): string;
|
||||||
|
/** Whether the request carries a valid token for its session (`Bun.CSRF.verify`). */
|
||||||
|
verify(request: M3Request): Promise<boolean>;
|
||||||
|
/** The route with its POST, PUT, PATCH and DELETE handlers refused (403) without a valid token. */
|
||||||
|
protect(route: M3Route): M3Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSRF tokens for an API, on `Bun.CSRF`. */
|
||||||
|
export function m3Csrf(options: M3CsrfOptions): M3Csrf {
|
||||||
|
const header = options.header ?? "x-csrf-token";
|
||||||
|
const verify = async (request: M3Request): Promise<boolean> => {
|
||||||
|
const token = request.headers.get(header);
|
||||||
|
if (!token) return false;
|
||||||
|
const sessionId = await options.sessionOf?.(request);
|
||||||
|
return Bun.CSRF.verify(token, {
|
||||||
|
secret: options.secret,
|
||||||
|
...(options.maxAge !== undefined ? { maxAge: options.maxAge } : {}),
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const guard =
|
||||||
|
(handler: M3Handler): M3Handler =>
|
||||||
|
async (request) =>
|
||||||
|
!UNSAFE.has(request.method) || (await verify(request))
|
||||||
|
? handler(request)
|
||||||
|
: Response.json({ error: "invalid CSRF token" }, { status: 403 });
|
||||||
|
return {
|
||||||
|
token: (sessionId) =>
|
||||||
|
Bun.CSRF.generate(options.secret, {
|
||||||
|
...(options.maxAge !== undefined ? { expiresIn: options.maxAge } : {}),
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
}),
|
||||||
|
verify,
|
||||||
|
protect(route) {
|
||||||
|
if (route instanceof Response) return route;
|
||||||
|
if (typeof route === "function") return guard(route);
|
||||||
|
const guarded: M3Methods = {};
|
||||||
|
for (const [method, handler] of Object.entries(route) as [keyof M3Methods, M3Handler][])
|
||||||
|
guarded[method] = guard(handler);
|
||||||
|
return guarded;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const YAML_TYPE = /^(?:application|text)\/(?:x-)?yaml\b/;
|
||||||
|
const JSONL_TYPE = /^application\/(?:jsonl|x-ndjson|jsonlines)\b/;
|
||||||
|
|
||||||
|
/** Wanted body format of a request (`Accept`): JSON unless YAML or JSON Lines is asked for. */
|
||||||
|
function wanted(accept: string | null): "json" | "yaml" | "jsonl" {
|
||||||
|
for (const part of (accept ?? "").toLowerCase().split(",")) {
|
||||||
|
const type = part.trim();
|
||||||
|
if (YAML_TYPE.test(type)) return "yaml";
|
||||||
|
if (JSONL_TYPE.test(type)) return "jsonl";
|
||||||
|
if (type.startsWith("application/json")) return "json";
|
||||||
|
}
|
||||||
|
return "json";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON Lines of `items`, streamed as they come (`application/jsonl`). */
|
||||||
|
export function jsonl(
|
||||||
|
items: Iterable<unknown> | AsyncIterable<unknown>,
|
||||||
|
init: ResponseInit = {},
|
||||||
|
): Response {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
for await (const item of items) controller.enqueue(encoder.encode(`${JSON.stringify(item)}\n`));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/jsonl; charset=utf-8");
|
||||||
|
return new Response(body, { ...init, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `data` as YAML (`Bun.YAML.stringify`). */
|
||||||
|
export function yaml(data: unknown, init: ResponseInit = {}): Response {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/yaml; charset=utf-8");
|
||||||
|
return new Response(Bun.YAML.stringify(data, null, 2), { ...init, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `data` in the format the request accepts: YAML, JSON Lines (arrays and iterables), else JSON. */
|
||||||
|
export function reply(data: unknown, request: Request, init: ResponseInit = {}): Response {
|
||||||
|
const format = wanted(request.headers.get("accept"));
|
||||||
|
const iterable =
|
||||||
|
data !== null &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
(Symbol.iterator in data || Symbol.asyncIterator in data);
|
||||||
|
const response =
|
||||||
|
format === "yaml"
|
||||||
|
? yaml(data, init)
|
||||||
|
: format === "jsonl" && iterable
|
||||||
|
? jsonl(data as Iterable<unknown> | AsyncIterable<unknown>, init)
|
||||||
|
: Response.json(data, init);
|
||||||
|
response.headers.append("Vary", "Accept");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each value of a JSON Lines request body as it arrives (`Bun.JSONL.parseChunk`); a malformed line throws. */
|
||||||
|
export async function* readJsonl(request: Request): AsyncGenerator<unknown> {
|
||||||
|
if (!request.body) return;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let pending = "";
|
||||||
|
for await (const chunk of request.body) {
|
||||||
|
pending += decoder.decode(chunk, { stream: true });
|
||||||
|
const { values, read, error } = Bun.JSONL.parseChunk(pending);
|
||||||
|
if (error) throw error;
|
||||||
|
yield* values;
|
||||||
|
pending = pending.slice(read);
|
||||||
|
}
|
||||||
|
pending += decoder.decode();
|
||||||
|
if (pending.trim() !== "") yield* Bun.JSONL.parse(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The request body by `Content-Type`: YAML (`Bun.YAML.parse`), JSON Lines (an array), form fields, else JSON. */
|
||||||
|
export async function readBody(request: Request): Promise<unknown> {
|
||||||
|
const type = (request.headers.get("content-type") ?? "").toLowerCase();
|
||||||
|
if (YAML_TYPE.test(type)) return Bun.YAML.parse(await request.text());
|
||||||
|
if (JSONL_TYPE.test(type)) return Array.fromAsync(readJsonl(request));
|
||||||
|
if (type.startsWith("multipart/form-data") || type.startsWith("application/x-www-form-urlencoded"))
|
||||||
|
return Object.fromEntries(await request.formData());
|
||||||
|
return request.json();
|
||||||
|
}
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun build`: the production build of an M3 app. It is `buildM3` of `@aphrody/m3` (one `Bun.build`) with the
|
||||||
|
// one m3-bun plugin, then a layout a file server and every deploy target can use as is:
|
||||||
|
// dist/index.html the entry, hoisted to the root with its asset references rewritten
|
||||||
|
// dist/chunk-<hash>.{js,css} content-hashed assets (immutable caching)
|
||||||
|
// dist/m3-bun.manifest.json files, sizes, gzip and zstd sizes, SHA-256 and a build id derived from them
|
||||||
|
import { buildM3, type BuildM3Options } from "@aphrody/m3";
|
||||||
|
import { heapStats, memoryUsage } from "bun:jsc";
|
||||||
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { readdir, rm } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { loadApp, type M3App } from "./config.ts";
|
||||||
|
import { graphPath, recordBuild, type GraphRecord } from "./graph.ts";
|
||||||
|
import { m3Bun } from "./plugin.ts";
|
||||||
|
import { sha256 } from "./util.ts";
|
||||||
|
|
||||||
|
export type MinifyLevel = "none" | "basic" | "full";
|
||||||
|
|
||||||
|
/** `Bun.build` minify options of a level: `basic` is whitespace and syntax, `full` adds identifiers. */
|
||||||
|
export function minifyOptions(level: MinifyLevel): NonNullable<BuildM3Options["minify"]> {
|
||||||
|
if (level === "none") return false;
|
||||||
|
return { whitespace: true, syntax: true, identifiers: level === "full" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildFile {
|
||||||
|
/** Path relative to the build directory, `/`-separated. */
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
gzipBytes: number;
|
||||||
|
/** `Bun.zstdCompressSync` size at the default level: what a `zstd` content encoding costs. */
|
||||||
|
zstdBytes: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildManifest {
|
||||||
|
schema: "m3-bun.manifest/1";
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
bun: string;
|
||||||
|
minify: MinifyLevel;
|
||||||
|
/** Hash of the file hashes: the identity of this build, reproducible for the same inputs. */
|
||||||
|
buildId: string;
|
||||||
|
entry: "index.html";
|
||||||
|
files: BuildFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Memory of the building process when the build finished (`bun:jsc`), in bytes. */
|
||||||
|
export interface BuildMemory {
|
||||||
|
/** JavaScriptCore heap after the last collection, plus extra memory. */
|
||||||
|
heapSize: number;
|
||||||
|
heapCapacity: number;
|
||||||
|
objectCount: number;
|
||||||
|
/** Resident set size, now and at its peak. */
|
||||||
|
rss: number;
|
||||||
|
peakRss: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildReport {
|
||||||
|
app: M3App;
|
||||||
|
outdir: string;
|
||||||
|
manifest: BuildManifest;
|
||||||
|
totalBytes: number;
|
||||||
|
totalGzipBytes: number;
|
||||||
|
totalZstdBytes: number;
|
||||||
|
ms: number;
|
||||||
|
memory: BuildMemory;
|
||||||
|
/** Problems that did not stop the build, such as a Bun outside the app's `engines.bun` range. */
|
||||||
|
warnings: string[];
|
||||||
|
/** The `bun:graph` record of this build, when the build was recorded. */
|
||||||
|
graph?: GraphRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the HTML entry that `Bun.build` placed at `<depth dirs>/index.html` to the build root and strip the
|
||||||
|
* `../` the entry used to reach its siblings. `depth` is the number of directories the entry sat in. Only `src`
|
||||||
|
* and `href` attributes are rewritten (`HTMLRewriter`), never text, comments or script bodies.
|
||||||
|
*/
|
||||||
|
export function hoistHtml(html: string, depth: number): string {
|
||||||
|
if (depth === 0) return html;
|
||||||
|
const rewriter = new HTMLRewriter().on("[src], [href]", {
|
||||||
|
element(element) {
|
||||||
|
for (const name of ["src", "href"]) {
|
||||||
|
const value = element.getAttribute(name);
|
||||||
|
const ups = value ? /^(?:\.\.\/)+/.exec(value)?.[0] : undefined;
|
||||||
|
if (!value || !ups) continue;
|
||||||
|
const remaining = ups.length / 3 - depth;
|
||||||
|
element.setAttribute(name, `${remaining > 0 ? "../".repeat(remaining) : "./"}${value.slice(ups.length)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rewriter.transform(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Bun.version` against the `engines.bun` range of the app's `package.json` (`Bun.semver`): a message when outside. */
|
||||||
|
export function checkEngine(appDir: string, version: string = Bun.version): string | undefined {
|
||||||
|
const file = join(appDir, "package.json");
|
||||||
|
if (!existsSync(file)) return undefined;
|
||||||
|
const range = (JSON.parse(readFileSync(file, "utf8")) as { engines?: { bun?: string } }).engines?.bun;
|
||||||
|
if (!range || Bun.semver.satisfies(version, range)) return undefined;
|
||||||
|
return `Bun ${version} does not satisfy engines.bun "${range}" of ${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMemory(): BuildMemory {
|
||||||
|
const heap = heapStats();
|
||||||
|
const usage = memoryUsage();
|
||||||
|
return {
|
||||||
|
heapSize: heap.heapSize,
|
||||||
|
heapCapacity: heap.heapCapacity,
|
||||||
|
objectCount: heap.objectCount,
|
||||||
|
rss: usage.current,
|
||||||
|
peakRss: usage.peak,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every file of a build directory with sizes and hashes; `manifestName` itself is not listed. */
|
||||||
|
export async function listBuild(outdir: string, manifestName = "m3-bun.manifest.json"): Promise<BuildFile[]> {
|
||||||
|
const files: BuildFile[] = [];
|
||||||
|
const paths = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: outdir, onlyFiles: true, dot: true }));
|
||||||
|
for (const path of paths.map(p => p.replaceAll("\\", "/")).toSorted()) {
|
||||||
|
if (path === manifestName || path.endsWith(".map")) continue;
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Hash and compress one asset at a time so memory is bounded by the largest asset.
|
||||||
|
const bytes = new Uint8Array(await Bun.file(join(outdir, path)).arrayBuffer());
|
||||||
|
files.push({
|
||||||
|
path,
|
||||||
|
bytes: bytes.length,
|
||||||
|
gzipBytes: Bun.gzipSync(bytes).length,
|
||||||
|
zstdBytes: Bun.zstdCompressSync(bytes).length,
|
||||||
|
sha256: sha256(bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildAppOptions {
|
||||||
|
/** App directory (default: the working directory). */
|
||||||
|
dir?: string;
|
||||||
|
/** `m3.config.ts` path, relative to `dir`. */
|
||||||
|
configFile?: string;
|
||||||
|
minify?: MinifyLevel;
|
||||||
|
/** Development build: no `NODE_ENV=production`, linked sourcemaps. */
|
||||||
|
dev?: boolean;
|
||||||
|
/** Override the output directory. */
|
||||||
|
outdir?: string;
|
||||||
|
/** A pre-loaded app, for callers that already read the config. */
|
||||||
|
app?: M3App;
|
||||||
|
/** Record the build in a `bun:graph` registry (default: `bun.graph` of the config): `true` for the app default, or a path. */
|
||||||
|
graph?: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the UI of an M3 app into its output directory. */
|
||||||
|
export async function buildApp(options: BuildAppOptions = {}): Promise<BuildReport> {
|
||||||
|
const started = performance.now();
|
||||||
|
const app = options.app ?? (await loadApp(resolve(options.dir ?? process.cwd()), options.configFile));
|
||||||
|
const outdir = resolve(options.outdir ?? app.outdir);
|
||||||
|
const minify = options.minify ?? app.minify;
|
||||||
|
const { m3 } = app;
|
||||||
|
const graph = options.graph ?? app.bun.graph ?? false;
|
||||||
|
const engine = checkEngine(app.dir);
|
||||||
|
|
||||||
|
const built = await buildM3({
|
||||||
|
entry: m3.entry,
|
||||||
|
outdir,
|
||||||
|
production: options.dev !== true,
|
||||||
|
splitting: app.bun.splitting ?? true,
|
||||||
|
minify: minifyOptions(minify),
|
||||||
|
...(m3.define ? { define: m3.define } : {}),
|
||||||
|
...(m3.drop ? { drop: m3.drop } : {}),
|
||||||
|
...(m3.optimizeImports ? { optimizeImports: m3.optimizeImports } : {}),
|
||||||
|
plugin: false,
|
||||||
|
plugins: [
|
||||||
|
m3Bun({
|
||||||
|
...(m3.theme ? { theme: m3.theme } : {}),
|
||||||
|
...(m3.root ? { root: m3.root } : {}),
|
||||||
|
configDir: m3.dir,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
cwd: m3.dir,
|
||||||
|
metafile: graph !== false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await hoistEntry(outdir, m3.dir, m3.entry);
|
||||||
|
|
||||||
|
const files = await listBuild(outdir);
|
||||||
|
const manifest: BuildManifest = {
|
||||||
|
schema: "m3-bun.manifest/1",
|
||||||
|
name: app.name,
|
||||||
|
version: app.version,
|
||||||
|
bun: Bun.version,
|
||||||
|
minify,
|
||||||
|
buildId: sha256(files.map(f => `${f.path}:${f.sha256}`).join("\n")).slice(0, 16),
|
||||||
|
entry: "index.html",
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
await Bun.write(join(outdir, "m3-bun.manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
const report: BuildReport = {
|
||||||
|
app,
|
||||||
|
outdir,
|
||||||
|
manifest,
|
||||||
|
totalBytes: files.reduce((n, f) => n + f.bytes, 0),
|
||||||
|
totalGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0),
|
||||||
|
totalZstdBytes: files.reduce((n, f) => n + f.zstdBytes, 0),
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
memory: buildMemory(),
|
||||||
|
warnings: engine ? [engine] : [],
|
||||||
|
};
|
||||||
|
if (graph === false) return report;
|
||||||
|
if (!built.metafile) throw new Error("m3-bun build: Bun.build returned no metafile to record");
|
||||||
|
report.graph = await recordBuild(report, built.metafile, graph === true ? graphPath(app) : resolve(m3.dir, graph));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Bun.build` keeps the HTML entry at its source-relative path (`src/index.html`): make it `index.html`. */
|
||||||
|
async function hoistEntry(outdir: string, appDir: string, entry: string): Promise<void> {
|
||||||
|
const rel = relative(appDir, entry).replaceAll("\\", "/");
|
||||||
|
const built = join(outdir, rel);
|
||||||
|
if (!existsSync(built)) throw new Error(`m3-bun: the build produced no ${rel} in ${outdir}`);
|
||||||
|
const depth = dirname(rel) === "." ? 0 : dirname(rel).split("/").length;
|
||||||
|
const html = hoistHtml(readFileSync(built, "utf8"), depth);
|
||||||
|
await Bun.write(join(outdir, "index.html"), html);
|
||||||
|
if (basename(built) !== "index.html" || depth > 0) {
|
||||||
|
await rm(built, { force: true });
|
||||||
|
// Drop the now empty source-relative directories.
|
||||||
|
for (let dir = dirname(built); dir !== outdir && dir.startsWith(outdir); dir = dirname(dir)) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Inspect the next parent only after removing its empty child.
|
||||||
|
if ((await readdir(dir)).length > 0) break;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The manifest of an existing build directory. */
|
||||||
|
export function readManifest(outdir: string): BuildManifest | undefined {
|
||||||
|
const file = join(outdir, "m3-bun.manifest.json");
|
||||||
|
return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as BuildManifest) : undefined;
|
||||||
|
}
|
||||||
+643
@@ -0,0 +1,643 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The m3-bun command line (`bin/m3-bun.ts`). Commands run in the app directory (the one with `m3.config.ts`).
|
||||||
|
//
|
||||||
|
// m3-bun dev [--port n] [--no-hmr] [--inspect] [--inspector-ui] dev server, HMR, UI and API in one process
|
||||||
|
// m3-bun build [--minify none|basic|full] [--dev] [--outdir d] [--json] [--graph[=registry]]
|
||||||
|
// m3-bun serve [--port n] [--host h] [--build] production server of the build directory
|
||||||
|
// m3-bun compile [--target t] [--outfile f] [--minify l] [--no-build] one executable
|
||||||
|
// m3-bun package [--target t] [--format tar.gz|zip] [--out d] [--rootfs] [--deploy] [--executable f]
|
||||||
|
// m3-bun deploy <target..|all> [--out d] [--domain h] [--name n] [--port n] [--image r] ... configs, never applied
|
||||||
|
// m3-bun graph [file] [--registry r] [--json] recorded builds (bun:graph), or what depends on a file
|
||||||
|
// m3-bun targets the deploy targets
|
||||||
|
// m3-bun smoke [executable] [--port n] run it (yolo runtime when installed), GET /, /healthz, /api/m3/info
|
||||||
|
// m3-bun workspace <build|typecheck|clean> [--only a,b] [--no-cache] the repository pipeline (scripts/build.ts)
|
||||||
|
// m3-bun pack [package-dir] [--out d] build a workspace package and `bun pm pack` it
|
||||||
|
// m3-bun lint [paths..] [--strict] [--fix] [--correctness] eslint-plugin-m3 rules through oxlint
|
||||||
|
// m3-bun migrate [--icons] [--glob g] [--dry-run] | --next <src> <out> [--m3] [--write] m3-codemods, m3-next-migrate
|
||||||
|
// m3-bun create <web|app|spa|example> <dir> [--name n] [--target tauri|vite] [--dry-run] scaffold
|
||||||
|
// m3-bun design "<brief>" [--color c] [--system s] [--validate] [--out d] m3-design
|
||||||
|
// m3-bun og [--dark] [--engine native|mcu] [--title t] [--subtitle s] [--out f.png|webp] canvas
|
||||||
|
// m3-bun lib [dir] [--outdir d] [--minify-lib] m3-config bun-build preset
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { buildApp, type MinifyLevel } from "./build.ts";
|
||||||
|
import { compileApp, defaultOutfile } from "./compile.ts";
|
||||||
|
import { loadDevApi } from "./dev-error.ts";
|
||||||
|
import { launchInspected, serveInspectorFrontend } from "./dev-inspect.ts";
|
||||||
|
import type { M3CssEngine } from "./theme-css.ts";
|
||||||
|
import { createApp } from "./tools-create.ts";
|
||||||
|
import { designApp } from "./tools-design.ts";
|
||||||
|
import { buildLibrary } from "./tools-lib.ts";
|
||||||
|
import { lintApp } from "./tools-lint.ts";
|
||||||
|
import { migrateApp, migrateNext } from "./tools-migrate.ts";
|
||||||
|
import { ogImage } from "./tools-og.ts";
|
||||||
|
import { dependents, graphPath, graphReport } from "./graph.ts";
|
||||||
|
import {
|
||||||
|
COMPILE_TARGETS,
|
||||||
|
findWorkspaceRoot,
|
||||||
|
loadApp,
|
||||||
|
type CompileTargetName,
|
||||||
|
type M3App,
|
||||||
|
} from "./config.ts";
|
||||||
|
import { packageApp, type ArchiveFormat } from "./package.ts";
|
||||||
|
import { formatSmoke, smoke } from "./runtime.ts";
|
||||||
|
import { dirSource, startM3Server } from "./server.ts";
|
||||||
|
import { TARGETS, contextFor, generateTargets } from "./targets/index.ts";
|
||||||
|
import { kib } from "./util.ts";
|
||||||
|
|
||||||
|
export const COMMANDS = [
|
||||||
|
"dev",
|
||||||
|
"build",
|
||||||
|
"serve",
|
||||||
|
"compile",
|
||||||
|
"package",
|
||||||
|
"deploy",
|
||||||
|
"graph",
|
||||||
|
"targets",
|
||||||
|
"smoke",
|
||||||
|
"workspace",
|
||||||
|
"pack",
|
||||||
|
"lint",
|
||||||
|
"migrate",
|
||||||
|
"create",
|
||||||
|
"design",
|
||||||
|
"og",
|
||||||
|
"lib",
|
||||||
|
"help",
|
||||||
|
"version",
|
||||||
|
] as const;
|
||||||
|
export type Command = (typeof COMMANDS)[number];
|
||||||
|
|
||||||
|
export interface Parsed {
|
||||||
|
command: Command;
|
||||||
|
positionals: string[];
|
||||||
|
flags: Map<string, string | true>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOLEAN = new Set([
|
||||||
|
"dev",
|
||||||
|
"no-hmr",
|
||||||
|
"no-build",
|
||||||
|
"build",
|
||||||
|
"json",
|
||||||
|
"rootfs",
|
||||||
|
"deploy",
|
||||||
|
"help",
|
||||||
|
"version",
|
||||||
|
"no-cache",
|
||||||
|
"graph",
|
||||||
|
"inspect",
|
||||||
|
"inspector-ui",
|
||||||
|
"strict",
|
||||||
|
"fix",
|
||||||
|
"correctness",
|
||||||
|
"icons",
|
||||||
|
"dry-run",
|
||||||
|
"next",
|
||||||
|
"m3",
|
||||||
|
"write",
|
||||||
|
"validate",
|
||||||
|
"dark",
|
||||||
|
"minify-lib",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Parse `argv` (without the runtime and script paths). */
|
||||||
|
export function parseArgs(argv: readonly string[]): Parsed {
|
||||||
|
const flags = new Map<string, string | true>();
|
||||||
|
const positionals: string[] = [];
|
||||||
|
let command: Command | undefined;
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
if (arg === "--") {
|
||||||
|
positionals.push(...argv.slice(i + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (arg.startsWith("--")) {
|
||||||
|
const eq = arg.indexOf("=");
|
||||||
|
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||||
|
if (eq !== -1) flags.set(name, arg.slice(eq + 1));
|
||||||
|
else if (BOOLEAN.has(name)) flags.set(name, true);
|
||||||
|
else {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) throw new Error(`--${name} needs a value`);
|
||||||
|
flags.set(name, next);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "-h") flags.set("help", true);
|
||||||
|
else if (arg === "-v") flags.set("version", true);
|
||||||
|
else if (command === undefined && (COMMANDS as readonly string[]).includes(arg))
|
||||||
|
command = arg as Command;
|
||||||
|
else positionals.push(arg);
|
||||||
|
}
|
||||||
|
if (flags.has("help")) command = "help";
|
||||||
|
else if (flags.has("version") && command === undefined) command = "version";
|
||||||
|
return { command: command ?? "help", positionals, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USAGE = `m3-bun: build, serve, compile, package and deploy configs for M3 apps
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
m3-bun dev [--port <n>] [--no-hmr] [--inspect] [--inspector-ui]
|
||||||
|
m3-bun build [--minify none|basic|full] [--dev] [--outdir <dir>] [--json] [--graph[=<registry>]]
|
||||||
|
m3-bun serve [--port <n>] [--host <h>] [--build]
|
||||||
|
m3-bun compile [--target <t>] [--outfile <file>] [--minify <level>] [--no-build]
|
||||||
|
m3-bun package [--target <t>] [--format tar.gz|zip] [--out <dir>] [--rootfs] [--deploy] [--executable <file>]
|
||||||
|
m3-bun deploy <target..|all> [--out <dir>] [--domain <host>] [--name <n>] [--port <n>] [--image <ref>]
|
||||||
|
[--namespace <ns>] [--user <u>] [--region <r>] [--tls-cert <f> --tls-key <f>] [--http2 directive|listen] [--cli "<cmd>"] [--icon <symbol>]
|
||||||
|
m3-bun graph [file] [--registry <file>] [--json]
|
||||||
|
m3-bun targets
|
||||||
|
m3-bun smoke [executable] [--port <n>]
|
||||||
|
m3-bun workspace <build|typecheck|clean> [--only a,b] [--no-cache]
|
||||||
|
m3-bun pack [package-dir] [--out <dir>]
|
||||||
|
m3-bun lint [paths..] [--strict] [--fix] [--correctness]
|
||||||
|
m3-bun migrate [--icons] [--glob <g>] [--dry-run]
|
||||||
|
m3-bun migrate --next <source> <target> [--name <n>] [--m3] [--write]
|
||||||
|
m3-bun create <web|app|spa|example> <dir> [--name <n>] [--target tauri|vite] [--dry-run]
|
||||||
|
m3-bun design "<brief>" [--color <hex>] [--system <s>] [--validate] [--out <dir>]
|
||||||
|
m3-bun og [--dark] [--engine native|mcu] [--title <t>] [--subtitle <s>] [--out <file.png|webp>]
|
||||||
|
m3-bun lib [dir] [--outdir <dir>] [--minify-lib]
|
||||||
|
|
||||||
|
Compile targets: ${COMPILE_TARGETS.join(", ")}
|
||||||
|
Deploy targets: ${TARGETS.map((t) => t.id).join(", ")} (generated, never applied)
|
||||||
|
Commands run in the directory of the app's m3.config.ts.`;
|
||||||
|
|
||||||
|
const str = (flags: Map<string, string | true>, name: string): string | undefined => {
|
||||||
|
const value = flags.get(name);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === true) throw new Error(`--${name} needs a value`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (flags: Map<string, string | true>, name: string): number | undefined => {
|
||||||
|
const value = str(flags, name);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0)
|
||||||
|
throw new Error(`--${name} must be a non-negative integer, got ${value}`);
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const level = (flags: Map<string, string | true>): MinifyLevel | undefined => {
|
||||||
|
const value = str(flags, "minify");
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value !== "none" && value !== "basic" && value !== "full")
|
||||||
|
throw new Error(`--minify must be none, basic or full, got ${value}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = (flags: Map<string, string | true>): CompileTargetName | undefined => {
|
||||||
|
const value = str(flags, "target");
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!(COMPILE_TARGETS as readonly string[]).includes(value))
|
||||||
|
throw new Error(`--target must be one of ${COMPILE_TARGETS.join(", ")}, got ${value}`);
|
||||||
|
return value as CompileTargetName;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function importApi(app: M3App) {
|
||||||
|
return app.api ? loadDevApi(app.api) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The m3 plugins Bun may bundle an app's HTML entry with. */
|
||||||
|
const knownPluginFiles = (): string[] => {
|
||||||
|
const files = [join(import.meta.dir, "plugin.ts")];
|
||||||
|
try {
|
||||||
|
files.push(Bun.resolveSync("@aphrody/m3/plugin", import.meta.dir));
|
||||||
|
} catch {
|
||||||
|
// @aphrody/m3 is a dependency; a failure here only narrows the match.
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `bunfig.toml` must list the plugin (`[serve.static] plugins`) for `Bun.serve`'s HTML bundling to apply it. */
|
||||||
|
export function bunfigHasPlugin(dir: string): boolean {
|
||||||
|
const file = join(dir, "bunfig.toml");
|
||||||
|
if (!existsSync(file)) return false;
|
||||||
|
const parsed = Bun.TOML.parse(readFileSync(file, "utf8")) as {
|
||||||
|
serve?: { static?: { plugins?: unknown } };
|
||||||
|
};
|
||||||
|
const plugins = parsed.serve?.static?.plugins;
|
||||||
|
if (!Array.isArray(plugins)) return false;
|
||||||
|
const known = knownPluginFiles();
|
||||||
|
return plugins.some((specifier) => {
|
||||||
|
if (typeof specifier !== "string") return false;
|
||||||
|
try {
|
||||||
|
return known.includes(Bun.resolveSync(specifier, dir));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(cmd: string[], cwd: string): Promise<number> {
|
||||||
|
console.log(`$ ${cmd.join(" ")} (${cwd})`);
|
||||||
|
const proc = Bun.spawn(cmd, { windowsHide: true, cwd, stdio: ["inherit", "inherit", "inherit"] });
|
||||||
|
return proc.exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the CLI; resolves to the exit code (`dev` and `serve` resolve once listening, the server keeps the process alive). */
|
||||||
|
export async function runCli(
|
||||||
|
argv: readonly string[],
|
||||||
|
cwd: string = process.cwd(),
|
||||||
|
): Promise<number> {
|
||||||
|
const { command, positionals, flags } = parseArgs(argv);
|
||||||
|
switch (command) {
|
||||||
|
case "help":
|
||||||
|
console.log(USAGE);
|
||||||
|
return 0;
|
||||||
|
case "version": {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(join(import.meta.dir, "..", "package.json"), "utf8"),
|
||||||
|
) as { version: string };
|
||||||
|
console.log(manifest.version);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "targets": {
|
||||||
|
for (const t of TARGETS) console.log(`${t.id.padEnd(12)} ${t.description}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "dev": {
|
||||||
|
if (flags.has("inspect")) {
|
||||||
|
const args = argv.filter((a) => a !== "--inspect" && a !== "--inspector-ui");
|
||||||
|
const inspected = await launchInspected({
|
||||||
|
cmd: [process.execPath, join(import.meta.dir, "..", "bin", "m3-bun.ts"), ...args],
|
||||||
|
cwd,
|
||||||
|
onConsole: (entry) => console.log(`[${entry.level}] ${entry.text}`),
|
||||||
|
onException: (e) =>
|
||||||
|
console.error(
|
||||||
|
`[exception] ${e.name}: ${e.message}${e.frames.map((frame) => "\n " + frame).join("")}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
console.log(`m3-bun dev --inspect: inspector ${inspected.inspectorUrl}`);
|
||||||
|
if (flags.has("inspector-ui"))
|
||||||
|
console.log(
|
||||||
|
` web inspector ${serveInspectorFrontend({ ws: inspected.inspectorUrl }).url}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
if (!bunfigHasPlugin(app.dir)) {
|
||||||
|
console.error(
|
||||||
|
`m3-bun dev: ${join(app.dir, "bunfig.toml")} must list the plugin so Bun can bundle the HTML entry:\n\n[serve.static]\nplugins = ["@aphrody/m3-bun/plugin"]`,
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const index = (
|
||||||
|
(await import(pathToFileURL(app.m3.entry).href)) as { default: import("bun").HTMLBundle }
|
||||||
|
).default;
|
||||||
|
const api = await importApi(app);
|
||||||
|
const port = num(flags, "port");
|
||||||
|
const server = startM3Server({
|
||||||
|
name: app.name,
|
||||||
|
version: app.version,
|
||||||
|
index,
|
||||||
|
hmr: !flags.has("no-hmr"),
|
||||||
|
...(api ? { api } : {}),
|
||||||
|
...(app.bun.csp !== undefined ? { csp: app.bun.csp } : {}),
|
||||||
|
port: port ?? app.port,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun dev ${app.name}: listening on ${server.url}${app.api ? " (UI + API, one process)" : ""}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "build": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
const minify = level(flags);
|
||||||
|
const outdir = str(flags, "outdir");
|
||||||
|
const graph = flags.get("graph");
|
||||||
|
const report = await buildApp({
|
||||||
|
app,
|
||||||
|
...(minify ? { minify } : {}),
|
||||||
|
...(outdir ? { outdir } : {}),
|
||||||
|
...(graph !== undefined ? { graph: graph === true ? true : resolve(cwd, graph) } : {}),
|
||||||
|
dev: flags.has("dev"),
|
||||||
|
});
|
||||||
|
if (flags.has("json"))
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
outdir: report.outdir,
|
||||||
|
ms: report.ms,
|
||||||
|
totalBytes: report.totalBytes,
|
||||||
|
totalGzipBytes: report.totalGzipBytes,
|
||||||
|
totalZstdBytes: report.totalZstdBytes,
|
||||||
|
memory: report.memory,
|
||||||
|
warnings: report.warnings,
|
||||||
|
manifest: report.manifest,
|
||||||
|
...(report.graph ? { graph: report.graph } : {}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
for (const file of report.manifest.files)
|
||||||
|
console.log(
|
||||||
|
` ${file.path.padEnd(34)} ${kib(file.bytes).padStart(11)} gzip ${kib(file.gzipBytes)}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`m3-bun build ${app.name}@${app.version}: ${report.manifest.files.length} files, ${kib(report.totalBytes)} (${kib(report.totalGzipBytes)} gzip, ${kib(report.totalZstdBytes)} zstd), minify ${report.manifest.minify}, build ${report.manifest.buildId}, ${report.ms} ms -> ${report.outdir}`,
|
||||||
|
);
|
||||||
|
for (const warning of report.warnings) console.warn(`warning: ${warning}`);
|
||||||
|
if (report.graph)
|
||||||
|
console.log(
|
||||||
|
` graph ${report.graph.nodes} nodes, ${report.graph.edges} edges, run ${report.graph.runId} -> ${report.graph.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "serve": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
if (flags.has("build") || !existsSync(join(app.outdir, "index.html")))
|
||||||
|
await buildApp({ app });
|
||||||
|
const api = await importApi(app);
|
||||||
|
const port = num(flags, "port");
|
||||||
|
const host = str(flags, "host");
|
||||||
|
const server = startM3Server({
|
||||||
|
name: app.name,
|
||||||
|
version: app.version,
|
||||||
|
files: dirSource(app.outdir),
|
||||||
|
...(api ? { api } : {}),
|
||||||
|
...(app.bun.csp !== undefined ? { csp: app.bun.csp } : {}),
|
||||||
|
port: port ?? app.port,
|
||||||
|
...(host ? { hostname: host } : {}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun serve ${app.name}: listening on ${server.url}${app.api ? " (UI + API, one process)" : ""}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "compile": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
const t = target(flags);
|
||||||
|
const outfile = str(flags, "outfile");
|
||||||
|
const minify = level(flags);
|
||||||
|
const report = await compileApp({
|
||||||
|
app,
|
||||||
|
...(t ? { target: t } : {}),
|
||||||
|
...(outfile ? { outfile } : {}),
|
||||||
|
...(minify ? { minify } : {}),
|
||||||
|
build: !flags.has("no-build"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun compile ${app.name}: ${report.outfile} (${kib(report.bytes)}, ${report.files} embedded files, target ${report.target}, ${report.bytecode ? "bytecode" : "no bytecode"}) in ${report.ms} ms`,
|
||||||
|
);
|
||||||
|
for (const warning of report.warnings) console.warn(`warning: ${warning}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "package": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
const t = target(flags);
|
||||||
|
const out = str(flags, "out");
|
||||||
|
const format = str(flags, "format") as ArchiveFormat | undefined;
|
||||||
|
if (format !== undefined && format !== "tar.gz" && format !== "zip")
|
||||||
|
throw new Error(`--format must be tar.gz or zip, got ${format}`);
|
||||||
|
const executable = str(flags, "executable");
|
||||||
|
const report = await packageApp({
|
||||||
|
app,
|
||||||
|
...(t ? { target: t } : {}),
|
||||||
|
...(out ? { out } : {}),
|
||||||
|
...(format ? { format } : {}),
|
||||||
|
...(executable ? { executable } : {}),
|
||||||
|
rootfs: flags.has("rootfs"),
|
||||||
|
deploy: flags.has("deploy"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun package: ${report.archive} (${kib(report.bytes)}, ${report.archiver})\n sha256 ${report.sha256}\n files ${report.files.join(", ")}`,
|
||||||
|
);
|
||||||
|
if (report.rootfs)
|
||||||
|
console.log(
|
||||||
|
` rootfs ${report.rootfs.dir}: ${kib(report.rootfs.bytes)}, ${report.rootfs.libraries.length} libraries (${report.rootfs.libraries.join(", ")})`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "deploy": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
const ids = positionals.length > 0 ? positionals : ["all"];
|
||||||
|
const out = resolve(cwd, str(flags, "out") ?? app.bun.deploy?.out ?? "deploy");
|
||||||
|
const cert = str(flags, "tls-cert");
|
||||||
|
const key = str(flags, "tls-key");
|
||||||
|
if ((cert === undefined) !== (key === undefined))
|
||||||
|
throw new Error("--tls-cert and --tls-key go together");
|
||||||
|
const domain = str(flags, "domain");
|
||||||
|
const image = str(flags, "image");
|
||||||
|
const namespace = str(flags, "namespace");
|
||||||
|
const user = str(flags, "user");
|
||||||
|
const region = str(flags, "region");
|
||||||
|
const cli = str(flags, "cli");
|
||||||
|
const http2 = str(flags, "http2");
|
||||||
|
if (http2 !== undefined && http2 !== "directive" && http2 !== "listen")
|
||||||
|
throw new Error(`--http2 must be directive or listen, got ${http2}`);
|
||||||
|
const name = str(flags, "name");
|
||||||
|
const port = num(flags, "port");
|
||||||
|
const icon = str(flags, "icon");
|
||||||
|
const context = contextFor(app, {
|
||||||
|
...(icon ? { iconSymbol: icon } : {}),
|
||||||
|
...(domain ? { domain } : {}),
|
||||||
|
...(image ? { image } : {}),
|
||||||
|
...(namespace ? { namespace } : {}),
|
||||||
|
...(user ? { user } : {}),
|
||||||
|
...(region ? { region } : {}),
|
||||||
|
...(cli ? { cli } : {}),
|
||||||
|
...(name ? { name, binary: name } : {}),
|
||||||
|
...(port !== undefined ? { port } : {}),
|
||||||
|
...(cert && key ? { tls: { cert, key } } : {}),
|
||||||
|
...(http2 ? { http2 } : {}),
|
||||||
|
});
|
||||||
|
const generated = await generateTargets(ids, context, out);
|
||||||
|
for (const g of generated)
|
||||||
|
console.log(`${g.id.padEnd(12)} ${g.files.length} files -> ${g.dir}`);
|
||||||
|
console.log("generated only: nothing was applied, pushed or reloaded");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "graph": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
const registry = str(flags, "registry");
|
||||||
|
const path = registry ? resolve(cwd, registry) : graphPath(app);
|
||||||
|
const file = positionals[0];
|
||||||
|
if (file === undefined) {
|
||||||
|
console.log(await graphReport(app, path));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const found = await dependents(app, file, path);
|
||||||
|
if (flags.has("json")) console.log(JSON.stringify(found, null, 2));
|
||||||
|
else
|
||||||
|
for (const node of found)
|
||||||
|
console.log(`${" ".repeat(node.depth - 1)}${node.label} (${node.kind})`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "smoke": {
|
||||||
|
const app = await loadApp(cwd);
|
||||||
|
const executable = resolve(cwd, positionals[0] ?? defaultOutfile(app, undefined));
|
||||||
|
if (!existsSync(executable))
|
||||||
|
throw new Error(`m3-bun smoke: ${executable} does not exist (m3-bun compile first)`);
|
||||||
|
const port = num(flags, "port") ?? 38000 + Math.floor(Math.random() * 2000);
|
||||||
|
const report = await smoke(executable, port);
|
||||||
|
console.log(formatSmoke(report));
|
||||||
|
return report.ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
case "workspace": {
|
||||||
|
const root = findWorkspaceRoot(cwd);
|
||||||
|
const script = root ? join(root, "scripts", "build.ts") : undefined;
|
||||||
|
if (!root || !script || !existsSync(script))
|
||||||
|
throw new Error("m3-bun workspace: no scripts/build.ts above the working directory");
|
||||||
|
const task = positionals[0] ?? "build";
|
||||||
|
const only = str(flags, "only");
|
||||||
|
return run(
|
||||||
|
[
|
||||||
|
"bun",
|
||||||
|
script,
|
||||||
|
task,
|
||||||
|
...(only ? ["--only", only] : []),
|
||||||
|
...(flags.has("no-cache") ? ["--no-cache"] : []),
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "pack": {
|
||||||
|
const dir = resolve(cwd, positionals[0] ?? ".");
|
||||||
|
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
const root = findWorkspaceRoot(dir);
|
||||||
|
const script = root ? join(root, "scripts", "build.ts") : undefined;
|
||||||
|
if (root && script && existsSync(script) && existsSync(join(dir, "build.config.ts"))) {
|
||||||
|
const code = await run(
|
||||||
|
["bun", script, "build", "--only", manifest.name, "--no-apps"],
|
||||||
|
root,
|
||||||
|
);
|
||||||
|
if (code !== 0) return code;
|
||||||
|
}
|
||||||
|
const out = resolve(cwd, str(flags, "out") ?? "release");
|
||||||
|
return run(["bun", "pm", "pack", "--destination", out], dir);
|
||||||
|
}
|
||||||
|
case "lint": {
|
||||||
|
const report = await lintApp({
|
||||||
|
dir: cwd,
|
||||||
|
...(positionals.length ? { paths: positionals } : {}),
|
||||||
|
preset: flags.has("strict") ? "strict" : "recommended",
|
||||||
|
fix: flags.has("fix"),
|
||||||
|
correctness: flags.has("correctness"),
|
||||||
|
});
|
||||||
|
for (const d of report.diagnostics)
|
||||||
|
console.log(`${d.file}:${d.line}:${d.column} ${d.severity} ${d.rule}: ${d.message}`);
|
||||||
|
console.log(
|
||||||
|
`m3-bun lint: ${report.files} files, ${report.rules} rules, ${report.errors} errors, ${report.warnings} warnings`,
|
||||||
|
);
|
||||||
|
return report.exitCode;
|
||||||
|
}
|
||||||
|
case "migrate": {
|
||||||
|
if (flags.has("next")) {
|
||||||
|
const [source, destination] = positionals;
|
||||||
|
if (!source || !destination) throw new Error("m3-bun migrate --next <source> <target>");
|
||||||
|
const name = str(flags, "name");
|
||||||
|
const report = await migrateNext({
|
||||||
|
source: resolve(cwd, source),
|
||||||
|
target: resolve(cwd, destination),
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
m3: flags.has("m3"),
|
||||||
|
write: flags.has("write"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun migrate --next: ${report.migratedFiles} migrated, ${report.unmodifiedFiles} unchanged, ${report.copiedAssets} assets, ${report.skippedFiles.length} skipped${flags.has("write") ? "" : " (dry run)"}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const glob = str(flags, "glob");
|
||||||
|
const report = await migrateApp({
|
||||||
|
dir: cwd,
|
||||||
|
kind: flags.has("icons") ? "icons" : "mui",
|
||||||
|
...(glob ? { glob } : {}),
|
||||||
|
dryRun: flags.has("dry-run"),
|
||||||
|
});
|
||||||
|
for (const file of report.changed)
|
||||||
|
console.log(
|
||||||
|
` ${file.path}${file.diagnostics.length ? ` (${file.diagnostics.length} TODO)` : ""}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`m3-bun migrate: ${report.changed.length}/${report.scanned} files changed${report.dryRun ? " (dry run)" : ""}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "create": {
|
||||||
|
const [template, dir] = positionals;
|
||||||
|
if (!template || !dir) throw new Error("m3-bun create <web|app|spa|example> <dir>");
|
||||||
|
if (!["web", "app", "spa", "example"].includes(template))
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun create: template must be web, app, spa or example, got ${template}`,
|
||||||
|
);
|
||||||
|
const name = str(flags, "name");
|
||||||
|
const t = str(flags, "target");
|
||||||
|
if (t !== undefined && t !== "tauri" && t !== "vite")
|
||||||
|
throw new Error(`--target must be tauri or vite, got ${t}`);
|
||||||
|
const report = await createApp({
|
||||||
|
template: template as "web" | "app" | "spa" | "example",
|
||||||
|
dir: resolve(cwd, dir),
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(t ? { target: t } : {}),
|
||||||
|
dryRun: flags.has("dry-run"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun create ${report.template} ${report.name}: ${report.files.length} files ${report.written ? "->" : "(dry run)"} ${report.dir}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "design": {
|
||||||
|
const prompt = positionals.join(" ");
|
||||||
|
if (!prompt) throw new Error(`m3-bun design "<brief>"`);
|
||||||
|
const color = str(flags, "color");
|
||||||
|
const system = str(flags, "system");
|
||||||
|
const out = str(flags, "out");
|
||||||
|
const report = await designApp({
|
||||||
|
prompt,
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
...(system ? { designSystem: system } : {}),
|
||||||
|
validate: flags.has("validate"),
|
||||||
|
...(out ? { out: resolve(cwd, out) } : {}),
|
||||||
|
});
|
||||||
|
for (const file of report.files) console.log(` ${file.path.padEnd(34)} ${kib(file.bytes)}`);
|
||||||
|
console.log(
|
||||||
|
`m3-bun design: seed ${report.seed}, ${report.files.length} files${report.written.length ? `, ${report.written.length} written` : ""}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "og": {
|
||||||
|
const engine = str(flags, "engine");
|
||||||
|
if (engine !== undefined && engine !== "native" && engine !== "mcu")
|
||||||
|
throw new Error(`--engine must be native or mcu, got ${engine}`);
|
||||||
|
const title = str(flags, "title");
|
||||||
|
const subtitle = str(flags, "subtitle");
|
||||||
|
const out = str(flags, "out");
|
||||||
|
const report = await ogImage({
|
||||||
|
dir: cwd,
|
||||||
|
dark: flags.has("dark"),
|
||||||
|
...(engine ? { engine: engine as M3CssEngine } : {}),
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
|
...(out ? { out: resolve(cwd, out) } : {}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`m3-bun og: ${report.file} (${report.width}x${report.height}, ${kib(report.bytes)})`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case "lib": {
|
||||||
|
const outdir = str(flags, "outdir");
|
||||||
|
const report = await buildLibrary({
|
||||||
|
dir: resolve(cwd, positionals[0] ?? "."),
|
||||||
|
...(outdir ? { outDir: outdir } : {}),
|
||||||
|
...(flags.has("minify-lib") ? { minify: true } : {}),
|
||||||
|
});
|
||||||
|
for (const log of report.logs) console.error(log);
|
||||||
|
for (const file of report.files)
|
||||||
|
console.log(
|
||||||
|
` ${file.path.padEnd(34)} ${kib(file.bytes).padStart(11)} zstd ${kib(file.zstdBytes)}`,
|
||||||
|
);
|
||||||
|
console.log(`m3-bun lib: ${report.files.length} files -> ${report.outDir}`);
|
||||||
|
return report.success ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun compile`: one executable that serves the UI and the API. The UI is built first (`buildApp`), then a
|
||||||
|
// generated entry embeds every build file (`import ... with { type: "file" }`), the API module and the
|
||||||
|
// runtime (`runApp`), and `Bun.build({ compile })` links them with the Bun runtime. Cross targets download the
|
||||||
|
// matching Bun runtime on first use.
|
||||||
|
//
|
||||||
|
// Bytecode: the app is compiled with `bytecode: true` (JavaScriptCore bytecode embedded next to the source), which
|
||||||
|
// skips parsing at start-up: on the fixture app `--version` takes 4.3 ms instead of 7.5 ms and the first /healthz
|
||||||
|
// 5.8 ms instead of 9.3 ms (medians, same host); the executable grows by about 1.5 MB. Bytecode output is CommonJS, so
|
||||||
|
// the generated entry has no top-level await. `compile.bytecode: false` in the app config turns it off.
|
||||||
|
//
|
||||||
|
// Limit, measured: the executable is the Bun runtime plus the app. It is not static. The glibc targets need
|
||||||
|
// libc, libm, libdl, libpthread and the loader at run time; the `-musl` targets need musl and libstdc++. A
|
||||||
|
// container or distro image therefore carries those libraries (see `rootfs` in package.ts and the `docker` and
|
||||||
|
// `linux-image` targets) instead of being empty.
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { buildApp, minifyOptions, readManifest, type BuildReport, type MinifyLevel } from "./build.ts";
|
||||||
|
import { compileTarget, loadApp, type CompileTargetName, type M3App } from "./config.ts";
|
||||||
|
|
||||||
|
export interface EntryParams {
|
||||||
|
/** Absolute path of `entry.ts` of this package. */
|
||||||
|
runtime: string;
|
||||||
|
/** Absolute path of the API module, if any. */
|
||||||
|
api?: string;
|
||||||
|
/** `[url path, absolute file path]` of every embedded file. */
|
||||||
|
files: ReadonlyArray<readonly [string, string]>;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
buildId: string;
|
||||||
|
port: number;
|
||||||
|
csp?: string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The source of the generated entry module. Pure, so tests read it. */
|
||||||
|
export function renderEntry(params: EntryParams): string {
|
||||||
|
const imports = params.files.map(([, file], i) => `import file${i} from ${JSON.stringify(file)} with { type: "file" };`);
|
||||||
|
const table = params.files.map(([url], i) => ` ${JSON.stringify(url)}: file${i},`);
|
||||||
|
return [
|
||||||
|
"// Generated by @aphrody/m3-bun compile. Do not edit.",
|
||||||
|
`import { runApp } from ${JSON.stringify(params.runtime)};`,
|
||||||
|
...(params.api ? [`import api from ${JSON.stringify(params.api)};`] : []),
|
||||||
|
...imports,
|
||||||
|
"",
|
||||||
|
// No top-level await: bytecode compilation emits CommonJS, which has none.
|
||||||
|
"runApp({",
|
||||||
|
` name: ${JSON.stringify(params.name)},`,
|
||||||
|
` version: ${JSON.stringify(params.version)},`,
|
||||||
|
` buildId: ${JSON.stringify(params.buildId)},`,
|
||||||
|
` port: ${params.port},`,
|
||||||
|
...(params.csp !== undefined ? [` csp: ${JSON.stringify(params.csp)},`] : []),
|
||||||
|
...(params.api ? [" api,"] : []),
|
||||||
|
" files: {",
|
||||||
|
...table,
|
||||||
|
" },",
|
||||||
|
"}).catch((error: unknown) => {",
|
||||||
|
" console.error(error);",
|
||||||
|
" process.exit(1);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default output path of the executable of `app` for `target`. */
|
||||||
|
export function defaultOutfile(app: M3App, target: CompileTargetName | undefined): string {
|
||||||
|
const configured = app.bun.compile?.outfile;
|
||||||
|
const suffix = (target ?? "").startsWith("windows") || (!target && process.platform === "win32") ? ".exe" : "";
|
||||||
|
if (configured) return resolve(app.dir, configured.endsWith(suffix) ? configured : `${configured}${suffix}`);
|
||||||
|
return join(app.dir, "bin", `${app.name}${target ? `-${target}` : ""}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompileOptions {
|
||||||
|
dir?: string;
|
||||||
|
configFile?: string;
|
||||||
|
/** Friendly target name (`linux-x64-musl`, `windows-x64`, ...). Default: the host. */
|
||||||
|
target?: CompileTargetName;
|
||||||
|
outfile?: string;
|
||||||
|
minify?: MinifyLevel;
|
||||||
|
/** Build the UI first (default true); false uses the existing build directory. */
|
||||||
|
build?: boolean;
|
||||||
|
app?: M3App;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompileReport {
|
||||||
|
app: M3App;
|
||||||
|
target: string;
|
||||||
|
outfile: string;
|
||||||
|
bytes: number;
|
||||||
|
ms: number;
|
||||||
|
entryFile: string;
|
||||||
|
build?: BuildReport;
|
||||||
|
files: number;
|
||||||
|
/** Things that did not apply as configured (for example Windows metadata when not compiling on Windows). */
|
||||||
|
warnings: string[];
|
||||||
|
/** The app was compiled with embedded JavaScriptCore bytecode (faster start). */
|
||||||
|
bytecode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compile the app into one executable. */
|
||||||
|
export async function compileApp(options: CompileOptions = {}): Promise<CompileReport> {
|
||||||
|
const started = performance.now();
|
||||||
|
const app = options.app ?? (await loadApp(resolve(options.dir ?? process.cwd()), options.configFile));
|
||||||
|
const target = options.target ?? app.bun.compile?.target;
|
||||||
|
const minify = options.minify ?? app.minify;
|
||||||
|
|
||||||
|
let build: BuildReport | undefined;
|
||||||
|
if (options.build !== false) build = await buildApp({ app, minify });
|
||||||
|
const manifest = build?.manifest ?? readManifest(app.outdir);
|
||||||
|
if (!manifest) throw new Error(`m3-bun: no build in ${app.outdir}; run m3-bun build or compile without --no-build`);
|
||||||
|
|
||||||
|
const files = manifest.files.map((f) => [`/${f.path}`, join(app.outdir, f.path)] as const);
|
||||||
|
const entryFile = join(app.dir, "node_modules", ".cache", "m3-bun", "server.ts");
|
||||||
|
await mkdir(dirname(entryFile), { recursive: true });
|
||||||
|
await Bun.write(
|
||||||
|
entryFile,
|
||||||
|
renderEntry({
|
||||||
|
runtime: join(import.meta.dir, "entry.ts"),
|
||||||
|
...(app.api ? { api: app.api } : {}),
|
||||||
|
files,
|
||||||
|
name: app.name,
|
||||||
|
version: app.version,
|
||||||
|
buildId: manifest.buildId,
|
||||||
|
port: app.port,
|
||||||
|
...(app.bun.csp !== undefined ? { csp: app.bun.csp } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const outfile = resolve(options.outfile ?? defaultOutfile(app, target));
|
||||||
|
await mkdir(dirname(outfile), { recursive: true });
|
||||||
|
// Bun applies Windows executable metadata (icon, title, publisher, ...) only when it runs on Windows; elsewhere the
|
||||||
|
// flags are refused on the command line and silently ignored by Bun.build, so say so instead of pretending.
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const wantsWindows = (target ?? "").startsWith("windows");
|
||||||
|
const configuredWindows = app.bun.compile?.windows;
|
||||||
|
const windows = wantsWindows && process.platform === "win32" ? configuredWindows : undefined;
|
||||||
|
if (wantsWindows && configuredWindows && process.platform !== "win32") {
|
||||||
|
warnings.push("compile.windows metadata (icon, title, publisher, version) is only applied when compiling on Windows; this executable has none (build-windows.ps1 applies it on a Windows host)");
|
||||||
|
}
|
||||||
|
const compileTargetString = compileTarget(target);
|
||||||
|
const bytecode = app.bun.compile?.bytecode !== false;
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [entryFile],
|
||||||
|
compile: {
|
||||||
|
outfile,
|
||||||
|
...(compileTargetString ? { target: compileTargetString } : {}),
|
||||||
|
autoloadDotenv: false,
|
||||||
|
autoloadBunfig: false,
|
||||||
|
...(windows ? { windows: { ...windows, ...(windows.icon ? { icon: resolve(app.dir, windows.icon) } : {}) } } : {}),
|
||||||
|
},
|
||||||
|
minify: minifyOptions(minify),
|
||||||
|
sourcemap: "none",
|
||||||
|
...(bytecode ? { bytecode: true } : {}),
|
||||||
|
define: { "process.env.NODE_ENV": '"production"' },
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
if (!result.success) throw new Error(`m3-bun: compile failed:\n${result.logs.map(String).join("\n")}`);
|
||||||
|
if (!existsSync(outfile)) throw new Error(`m3-bun: compile reported success but wrote no ${outfile}`);
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
target: compileTargetString ?? `host (${process.platform}-${process.arch})`,
|
||||||
|
outfile,
|
||||||
|
bytes: Bun.file(outfile).size,
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
entryFile,
|
||||||
|
...(build ? { build } : {}),
|
||||||
|
files: files.length,
|
||||||
|
warnings,
|
||||||
|
bytecode,
|
||||||
|
};
|
||||||
|
}
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// What an app declares for m3-bun, next to its `m3.config.ts`:
|
||||||
|
//
|
||||||
|
// import { defineM3Config } from "@aphrody/m3/config";
|
||||||
|
// import { defineM3Bun } from "@aphrody/m3-bun/config";
|
||||||
|
// export default defineM3Config({ entry: "src/index.html", theme: { seed: "#6750a4" } });
|
||||||
|
// export const bun = defineM3Bun({ api: "src/api.ts", port: 3000, deploy: { domain: "showcase.example.com" } });
|
||||||
|
//
|
||||||
|
// `bun` is optional; every field has a default derived from `package.json` and `m3.config.ts`.
|
||||||
|
import { loadM3Config, type ResolvedM3Config } from "@aphrody/m3/config";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { slug } from "./util.ts";
|
||||||
|
|
||||||
|
/** `bun build --compile` targets, without the `bun-` prefix. */
|
||||||
|
export const COMPILE_TARGETS = [
|
||||||
|
"linux-x64",
|
||||||
|
"linux-x64-baseline",
|
||||||
|
"linux-x64-musl",
|
||||||
|
"linux-arm64",
|
||||||
|
"linux-arm64-musl",
|
||||||
|
"windows-x64",
|
||||||
|
"windows-x64-baseline",
|
||||||
|
"darwin-x64",
|
||||||
|
"darwin-arm64",
|
||||||
|
] as const;
|
||||||
|
export type CompileTargetName = (typeof COMPILE_TARGETS)[number];
|
||||||
|
|
||||||
|
export interface M3BunDeploy {
|
||||||
|
/** Directory the targets are generated into (default `deploy`). */
|
||||||
|
out?: string;
|
||||||
|
/** Public host name (nginx, Fly, Coolify, Cloudflare routes). */
|
||||||
|
domain?: string;
|
||||||
|
/** Container image reference (default `ghcr.io/aphrody-labs/<name>`). */
|
||||||
|
image?: string;
|
||||||
|
/** Install namespace under `~/apps` and `/etc` for systemd (default `aphrody`). */
|
||||||
|
namespace?: string;
|
||||||
|
/** Unprivileged account of the systemd unit (default `ubuntu`, the yolo host convention). */
|
||||||
|
user?: string;
|
||||||
|
/** Fly.io primary region (default `cdg`). */
|
||||||
|
region?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3BunConfig {
|
||||||
|
/** App name (default: `package.json` name without scope). */
|
||||||
|
name?: string;
|
||||||
|
/** Version supplied by the product owning an embedded module. */
|
||||||
|
version?: string;
|
||||||
|
/** API module, relative to the config file: its default export is the routes. */
|
||||||
|
api?: string;
|
||||||
|
/** Port of the server (default 3000; `PORT` overrides at run time). */
|
||||||
|
port?: number;
|
||||||
|
/** UI build output directory (default: `outdir` of `m3.config.ts`, else `dist`). */
|
||||||
|
outdir?: string;
|
||||||
|
/** Minification of `m3-bun build`: `none`, `basic` (whitespace and syntax) or `full` (default, with identifiers). */
|
||||||
|
minify?: "none" | "basic" | "full";
|
||||||
|
/** Code splitting of the UI build (default true): dynamically imported pages become content-hashed chunks. */
|
||||||
|
splitting?: boolean;
|
||||||
|
/** Content-Security-Policy of the file server, or `false` to omit it. */
|
||||||
|
csp?: string | false;
|
||||||
|
/** Record every `m3-bun build` in a `bun:graph` registry: `true` for `.m3-bun/graph.sqlite`, or its path relative to the config. */
|
||||||
|
graph?: boolean | string;
|
||||||
|
compile?: {
|
||||||
|
/** Default target of `m3-bun compile` (default: the host). */
|
||||||
|
target?: CompileTargetName;
|
||||||
|
/** Output file (default `<outdir>/<name>`; `.exe` is added for Windows). */
|
||||||
|
outfile?: string;
|
||||||
|
/** Embed JavaScriptCore bytecode for a faster start (default true; the executable grows by about 1.5 MB). */
|
||||||
|
bytecode?: boolean;
|
||||||
|
/** Windows executable metadata. */
|
||||||
|
windows?: {
|
||||||
|
icon?: string;
|
||||||
|
title?: string;
|
||||||
|
publisher?: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
copyright?: string;
|
||||||
|
hideConsole?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deploy?: M3BunDeploy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity helper that types the `bun` export of `m3.config.ts`. */
|
||||||
|
export function defineM3Bun(config: M3BunConfig): M3BunConfig {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3App {
|
||||||
|
dir: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
/** The resolved `m3.config.ts`. */
|
||||||
|
m3: ResolvedM3Config;
|
||||||
|
bun: M3BunConfig;
|
||||||
|
/** Absolute path of the API module, if any. */
|
||||||
|
api?: string;
|
||||||
|
port: number;
|
||||||
|
/** Absolute path of the UI build directory. */
|
||||||
|
outdir: string;
|
||||||
|
minify: "none" | "basic" | "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINIFY = new Set(["none", "basic", "full"]);
|
||||||
|
|
||||||
|
/** Load `m3.config.ts` and its optional `bun` export from `dir` and resolve every default. */
|
||||||
|
export async function loadApp(dir: string = process.cwd(), configFile?: string): Promise<M3App> {
|
||||||
|
const m3 = await loadM3Config(dir, configFile);
|
||||||
|
const mod = (await import(pathToFileURL(m3.file ?? join(m3.dir, "m3.config.ts")).href)) as {
|
||||||
|
bun?: M3BunConfig;
|
||||||
|
};
|
||||||
|
const bun = mod.bun ?? {};
|
||||||
|
if (bun.minify !== undefined && !MINIFY.has(bun.minify))
|
||||||
|
throw new Error(`m3-bun: minify must be none, basic or full, got ${String(bun.minify)}`);
|
||||||
|
const manifestFile = join(m3.dir, "package.json");
|
||||||
|
const manifest = existsSync(manifestFile)
|
||||||
|
? (JSON.parse(readFileSync(manifestFile, "utf8")) as { name?: string; version?: string })
|
||||||
|
: {};
|
||||||
|
const api = bun.api ? resolve(m3.dir, bun.api) : undefined;
|
||||||
|
if (api && !existsSync(api)) throw new Error(`m3-bun: api module not found: ${api}`);
|
||||||
|
return {
|
||||||
|
dir: m3.dir,
|
||||||
|
name: slug(bun.name ?? manifest.name ?? basename(m3.dir)),
|
||||||
|
version: bun.version ?? manifest.version ?? "0.0.0",
|
||||||
|
m3,
|
||||||
|
bun,
|
||||||
|
...(api ? { api } : {}),
|
||||||
|
port: bun.port ?? m3.port ?? 3000,
|
||||||
|
outdir: resolve(m3.dir, bun.outdir ?? m3.outdir),
|
||||||
|
minify: bun.minify ?? "full",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `bun build --compile` target string for a friendly name or the host. */
|
||||||
|
export function compileTarget(
|
||||||
|
name: CompileTargetName | undefined,
|
||||||
|
): Bun.Build.CompileTarget | undefined {
|
||||||
|
if (!name) return undefined;
|
||||||
|
if (!(COMPILE_TARGETS as readonly string[]).includes(name))
|
||||||
|
throw new Error(`m3-bun: unknown compile target ${name} (${COMPILE_TARGETS.join(", ")})`);
|
||||||
|
return `bun-${name}` as Bun.Build.CompileTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk up from `start` to the directory holding a `package.json` with `workspaces`. */
|
||||||
|
export function findWorkspaceRoot(start: string): string | undefined {
|
||||||
|
let dir = resolve(start);
|
||||||
|
for (;;) {
|
||||||
|
const file = join(dir, "package.json");
|
||||||
|
if (
|
||||||
|
existsSync(file) &&
|
||||||
|
(JSON.parse(readFileSync(file, "utf8")) as { workspaces?: unknown }).workspaces
|
||||||
|
)
|
||||||
|
return dir;
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) return undefined;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Errors of the app's API in `m3-bun dev` go to Bun's own error overlay (the `bun-error` page that `Bun.serve` renders
|
||||||
|
// in development for an uncaught handler error, with the source-mapped stack). An API module that fails to load does
|
||||||
|
// not stop the dev server: its routes rethrow the load error, so the overlay shows it on every API request until the
|
||||||
|
// file is fixed.
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { M3ApiModule } from "./api.ts";
|
||||||
|
|
||||||
|
/** An API whose every route under `prefix` (default `/api`) rethrows `error`. */
|
||||||
|
export function failingApi(error: unknown, prefix = "/api"): M3ApiModule {
|
||||||
|
const fail = (): never => {
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
const base = prefix.replace(/\/+$/, "");
|
||||||
|
return { routes: { [base]: fail, [`${base}/*`]: fail } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default export of the API module at `path`, or a `failingApi` carrying its load error. */
|
||||||
|
export async function loadDevApi(path: string): Promise<M3ApiModule> {
|
||||||
|
try {
|
||||||
|
const module = (await import(pathToFileURL(path).href)) as { default?: M3ApiModule };
|
||||||
|
return module.default ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
return failingApi(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun dev --inspect`: the dev server runs in a child Bun with its inspector on (`BUN_INSPECT`), the way the Bun
|
||||||
|
// debug adapter launches programs (`@aphrody/bun-debug-adapter-protocol`: free port, random path, TCP signal the child
|
||||||
|
// pings once its inspector listens). The parent attaches with `@aphrody/bun-inspector-protocol`'s `WebSocketInspector`
|
||||||
|
// and relays the child's console and uncaught exceptions; `serveInspectorFrontend` serves the WebKit Web Inspector
|
||||||
|
// of `@aphrody/web-inspector-bun`, connected to the same inspector through its `?ws=` parameter.
|
||||||
|
import type { Server, Subprocess } from "bun";
|
||||||
|
import { createM3App, dirSource } from "./server.ts";
|
||||||
|
import { loadOptional, packageDir } from "./server-optional.ts";
|
||||||
|
|
||||||
|
// The parts used of the two packages, typed here: they ship their TypeScript sources, which this package's strict
|
||||||
|
// compiler settings would otherwise check.
|
||||||
|
interface Dap {
|
||||||
|
getAvailablePort(): Promise<number>;
|
||||||
|
getRandomId(): string;
|
||||||
|
TCPSocketSignal: new (port: number) => {
|
||||||
|
readonly ready: Promise<void>;
|
||||||
|
readonly url: string;
|
||||||
|
once(event: "Signal.received", listener: () => void): unknown;
|
||||||
|
close(): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface RemoteObject {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
interface Inspector {
|
||||||
|
start(): Promise<boolean>;
|
||||||
|
send(method: string, params?: object): Promise<unknown>;
|
||||||
|
on(
|
||||||
|
event: "Console.messageAdded",
|
||||||
|
listener: (event: {
|
||||||
|
message: { level: string; text: string; parameters?: RemoteObject[] };
|
||||||
|
}) => void,
|
||||||
|
): unknown;
|
||||||
|
on(
|
||||||
|
event: "LifecycleReporter.error",
|
||||||
|
listener: (event: {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
urls: string[];
|
||||||
|
lineColumns: number[];
|
||||||
|
}) => void,
|
||||||
|
): unknown;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
interface Protocol {
|
||||||
|
WebSocketInspector: new (url: string) => Inspector;
|
||||||
|
remoteObjectToString(value: RemoteObject, topLevel?: boolean): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type M3ConsoleLevel = "log" | "info" | "warning" | "error" | "debug";
|
||||||
|
|
||||||
|
export interface M3ConsoleEntry {
|
||||||
|
level: M3ConsoleLevel;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3InspectedException {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
/** `url:line:column` frames, innermost first. */
|
||||||
|
frames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3InspectOptions {
|
||||||
|
/** The command, run by Bun (`[process.execPath, "server.ts"]`). */
|
||||||
|
cmd: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
/** Hold the program until the relay is attached, so no early output is lost (default true). */
|
||||||
|
wait?: boolean;
|
||||||
|
onConsole?(entry: M3ConsoleEntry): void;
|
||||||
|
onException?(exception: M3InspectedException): void;
|
||||||
|
/** Child output (default `inherit`). */
|
||||||
|
stdio?: "inherit" | "pipe" | "ignore";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3Inspected {
|
||||||
|
readonly child: Subprocess<
|
||||||
|
"ignore",
|
||||||
|
"inherit" | "pipe" | "ignore",
|
||||||
|
"inherit" | "pipe" | "ignore"
|
||||||
|
>;
|
||||||
|
/** Inspector WebSocket of the child (`ws://127.0.0.1:<port>/<id>`). */
|
||||||
|
readonly inspectorUrl: string;
|
||||||
|
/** Resolves once the relay is attached. */
|
||||||
|
readonly attached: Promise<void>;
|
||||||
|
readonly inspector: Promise<Inspector>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE = "dev --inspect";
|
||||||
|
|
||||||
|
const LEVELS: Record<string, M3ConsoleLevel> = {
|
||||||
|
log: "log",
|
||||||
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
error: "error",
|
||||||
|
debug: "debug",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run `options.cmd` under the inspector and relay its console and uncaught exceptions. */
|
||||||
|
export async function launchInspected(options: M3InspectOptions): Promise<M3Inspected> {
|
||||||
|
const [dap, protocol] = await Promise.all([
|
||||||
|
loadOptional<Dap>("@aphrody/bun-debug-adapter-protocol", FEATURE),
|
||||||
|
loadOptional<Protocol>("@aphrody/bun-inspector-protocol", FEATURE),
|
||||||
|
]);
|
||||||
|
const inspectorUrl = `ws://127.0.0.1:${await dap.getAvailablePort()}/${dap.getRandomId()}`;
|
||||||
|
const signal = new dap.TCPSocketSignal(0);
|
||||||
|
await signal.ready;
|
||||||
|
const wait = options.wait ?? true;
|
||||||
|
const stdio = options.stdio ?? "inherit";
|
||||||
|
const received = Promise.withResolvers<void>();
|
||||||
|
signal.once("Signal.received", () => received.resolve());
|
||||||
|
const child = Bun.spawn(options.cmd, {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: {
|
||||||
|
...(options.env ?? process.env),
|
||||||
|
BUN_INSPECT: wait ? `${inspectorUrl}?wait=1` : inspectorUrl,
|
||||||
|
BUN_INSPECT_NOTIFY: signal.url,
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: stdio,
|
||||||
|
stderr: stdio,
|
||||||
|
});
|
||||||
|
void child.exited.then(() => {
|
||||||
|
signal.close();
|
||||||
|
return received.reject(
|
||||||
|
new Error(`m3-bun ${FEATURE}: the program exited before its inspector listened`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inspector = received.promise.then(async () => {
|
||||||
|
const client = new protocol.WebSocketInspector(inspectorUrl);
|
||||||
|
client.on("Console.messageAdded", ({ message }) => {
|
||||||
|
const text = message.parameters?.length
|
||||||
|
? message.parameters.map((value) => protocol.remoteObjectToString(value, true)).join(" ")
|
||||||
|
: message.text;
|
||||||
|
options.onConsole?.({ level: LEVELS[message.level] ?? "log", text });
|
||||||
|
});
|
||||||
|
client.on("LifecycleReporter.error", ({ name, message, urls, lineColumns }) => {
|
||||||
|
const frames = urls.map(
|
||||||
|
(url, i) => `${url}:${lineColumns[i * 2] ?? 0}:${lineColumns[i * 2 + 1] ?? 0}`,
|
||||||
|
);
|
||||||
|
options.onException?.({ name, message, frames });
|
||||||
|
});
|
||||||
|
if (!(await client.start()))
|
||||||
|
throw new Error(`m3-bun ${FEATURE}: cannot attach to ${inspectorUrl}`);
|
||||||
|
await Promise.all([
|
||||||
|
client.send("Inspector.enable"),
|
||||||
|
client.send("Runtime.enable"),
|
||||||
|
client.send("Console.enable"),
|
||||||
|
client.send("LifecycleReporter.enable"),
|
||||||
|
]);
|
||||||
|
await client.send("Inspector.initialized");
|
||||||
|
signal.close();
|
||||||
|
return client;
|
||||||
|
});
|
||||||
|
inspector.catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
child,
|
||||||
|
inspectorUrl,
|
||||||
|
inspector,
|
||||||
|
attached: inspector.then(() => undefined),
|
||||||
|
async stop() {
|
||||||
|
void inspector.then(
|
||||||
|
(client) => client.close(),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
child.kill();
|
||||||
|
await child.exited;
|
||||||
|
signal.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3InspectorFrontend {
|
||||||
|
readonly server: Server<undefined>;
|
||||||
|
/** The Web Inspector page connected to `ws`. */
|
||||||
|
readonly url: string;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve the WebKit Web Inspector of `@aphrody/web-inspector-bun`, connected to the inspector at `ws`. */
|
||||||
|
export function serveInspectorFrontend(options: {
|
||||||
|
ws: string;
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
}): M3InspectorFrontend {
|
||||||
|
const dir = packageDir("@aphrody/web-inspector-bun", FEATURE);
|
||||||
|
// The frontend's own pages carry inline scripts; the default CSP would block them.
|
||||||
|
const app = createM3App({
|
||||||
|
name: "web-inspector",
|
||||||
|
files: dirSource(dir),
|
||||||
|
csp: false,
|
||||||
|
spa: false,
|
||||||
|
health: false,
|
||||||
|
});
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: options.port ?? 0,
|
||||||
|
hostname: options.hostname ?? "127.0.0.1",
|
||||||
|
routes: app.routes,
|
||||||
|
fetch: app.fetch,
|
||||||
|
});
|
||||||
|
const page = new URL("/index.html", server.url);
|
||||||
|
page.searchParams.set("ws", options.ws.replace(/^wss?:\/\//, ""));
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
url: page.href,
|
||||||
|
stop: () => server.stop(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The runtime of a compiled M3 app: `m3-bun compile` generates a tiny module that calls `runApp` with the
|
||||||
|
// embedded files and the API, and `bun build --compile` turns it into one executable.
|
||||||
|
//
|
||||||
|
// ./app serve on $PORT (default 3000), $HOST
|
||||||
|
// ./app --port 8080 choose the port
|
||||||
|
// ./app --healthcheck GET /healthz of the running instance, exit 0 or 1 (a container HEALTHCHECK with no shell)
|
||||||
|
// ./app --version
|
||||||
|
import type { M3ApiModule } from "./api.ts";
|
||||||
|
import { embeddedSource, startM3Server, type M3AppOptions } from "./server.ts";
|
||||||
|
|
||||||
|
export interface RunAppOptions extends Omit<M3AppOptions, "files"> {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
buildId: string;
|
||||||
|
port: number;
|
||||||
|
/** URL path -> embedded file path. */
|
||||||
|
files: Readonly<Record<string, string>>;
|
||||||
|
api?: M3ApiModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value after `--name` or `--name=value`. */
|
||||||
|
export function flagValue(argv: readonly string[], name: string): string | undefined {
|
||||||
|
const i = argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
||||||
|
if (i === -1) return undefined;
|
||||||
|
const arg = argv[i]!;
|
||||||
|
return arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the app: the command line first, then the server. Resolves when the process should exit, never while serving. */
|
||||||
|
export async function runApp(options: RunAppOptions, argv: readonly string[] = Bun.argv.slice(2)): Promise<void> {
|
||||||
|
if (argv.includes("--version")) {
|
||||||
|
console.log(`${options.name} ${options.version} (${options.buildId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const portFlag = flagValue(argv, "port");
|
||||||
|
const port = Bun.env.PORT ? Number(Bun.env.PORT) : portFlag ? Number(portFlag) : options.port;
|
||||||
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`${options.name}: invalid port ${String(port)}`);
|
||||||
|
|
||||||
|
if (argv.includes("--healthcheck")) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}${options.health === false ? "/" : (options.health ?? "/healthz")}`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
process.exitCode = response.ok ? 0 : 1;
|
||||||
|
} catch {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { files, ...rest } = options;
|
||||||
|
const hostname = Bun.env.HOST;
|
||||||
|
const server = startM3Server({ ...rest, files: embeddedSource(files, options.buildId), port, ...(hostname ? { hostname } : {}) });
|
||||||
|
console.log(`${options.name} ${options.version} listening on ${server.url}`);
|
||||||
|
const stop = () => {
|
||||||
|
void server.stop(true).then(() => process.exit(0));
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", stop);
|
||||||
|
process.on("SIGINT", stop);
|
||||||
|
// Keep the process alive: Bun.serve holds the event loop; a never-resolving promise documents it.
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The build graph of an M3 app in `bun:graph`, the SQLite registry of the Aphrody fork of Bun: the app is a
|
||||||
|
// repository, each `m3-bun build` a run (status, duration sample, output artifacts with their SHA-256) and the
|
||||||
|
// module graph of `Bun.build`'s metafile a snapshot (inputs, outputs, imports, which input went into which output).
|
||||||
|
// `m3-bun graph` reads it back: the markdown report of `bun:graphx`, or what depends on a file.
|
||||||
|
import type { BuildMetafile } from "bun";
|
||||||
|
import type { BunPython } from "bun:graph";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import type { BuildReport } from "./build.ts";
|
||||||
|
import type { M3App } from "./config.ts";
|
||||||
|
import { sha256 } from "./util.ts";
|
||||||
|
|
||||||
|
/** Registry of an app when no path is given: next to its config, out of the build directory. */
|
||||||
|
export const graphPath = (app: M3App): string => join(app.dir, ".m3-bun", "graph.sqlite");
|
||||||
|
|
||||||
|
/** Repository id of an app in the registry. */
|
||||||
|
export const repositoryId = (app: M3App): string => `m3-app:${app.name}`;
|
||||||
|
|
||||||
|
/** `bun:graph` exists in the Aphrody fork of Bun only. */
|
||||||
|
export async function openGraph(path: string): Promise<BunPython> {
|
||||||
|
let graph: typeof import("bun:graph");
|
||||||
|
try {
|
||||||
|
graph = await import("bun:graph");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun graph: this Bun (${Bun.version}+${Bun.revision.slice(0, 9)}) has no bun:graph module; run m3-bun with a build of the Aphrody fork of Bun that ships it`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new graph.BunPython(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A type alias, not an interface: `importGraph` takes records with an index signature.
|
||||||
|
export type ModuleGraph = {
|
||||||
|
producer: "m3-bun";
|
||||||
|
nodes: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
file_type: string;
|
||||||
|
source_file?: string;
|
||||||
|
provenance: "EXTRACTED";
|
||||||
|
bytes: number;
|
||||||
|
}[];
|
||||||
|
links: {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
relation: string;
|
||||||
|
confidence: "EXTRACTED";
|
||||||
|
confidence_score: 1;
|
||||||
|
bytes?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = (path: string, entry: string): string => {
|
||||||
|
const clean = path.replace(/^\.\//, "");
|
||||||
|
// `buildApp` hoists the HTML entry to the build root.
|
||||||
|
return `output:${clean === entry ? "index.html" : clean}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVirtual = (path: string): boolean => /^[a-z][a-z0-9+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metafile as graph nodes and links. Inputs are `input:<path relative to dir>` (the metafile keys them by the
|
||||||
|
* working directory; virtual modules keep their `m3:` specifier), outputs `output:<path in the build directory>`; links are the imports (relation: the import kind)
|
||||||
|
* and `bundled-into` from an input to every output it contributed bytes to.
|
||||||
|
*/
|
||||||
|
export function moduleGraph(metafile: BuildMetafile, entry: string, dir: string = process.cwd()): ModuleGraph {
|
||||||
|
const local = (path: string): string => (isVirtual(path) ? path : relative(dir, resolve(path)).replaceAll("\\", "/"));
|
||||||
|
const nodes: ModuleGraph["nodes"] = [];
|
||||||
|
const links: ModuleGraph["links"] = [];
|
||||||
|
const external = new Set<string>();
|
||||||
|
for (const [key, input] of Object.entries(metafile.inputs)) {
|
||||||
|
const virtual = isVirtual(key);
|
||||||
|
const path = local(key);
|
||||||
|
nodes.push({
|
||||||
|
id: `input:${path}`,
|
||||||
|
label: path,
|
||||||
|
file_type: virtual ? "virtual" : (input.format ?? "file"),
|
||||||
|
...(virtual ? {} : { source_file: path }),
|
||||||
|
provenance: "EXTRACTED",
|
||||||
|
bytes: input.bytes,
|
||||||
|
});
|
||||||
|
for (const imported of input.imports) {
|
||||||
|
let target = `input:${local(imported.path)}`;
|
||||||
|
if (imported.entryPoint) target = output(imported.path, entry);
|
||||||
|
else if (imported.external) {
|
||||||
|
target = `external:${imported.path}`;
|
||||||
|
external.add(imported.path);
|
||||||
|
}
|
||||||
|
links.push({
|
||||||
|
source: `input:${path}`,
|
||||||
|
target,
|
||||||
|
relation: imported.kind,
|
||||||
|
confidence: "EXTRACTED",
|
||||||
|
confidence_score: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const path of external)
|
||||||
|
nodes.push({ id: `external:${path}`, label: path, file_type: "external", provenance: "EXTRACTED", bytes: 0 });
|
||||||
|
for (const [path, built] of Object.entries(metafile.outputs)) {
|
||||||
|
const id = output(path, entry);
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
label: id.slice("output:".length),
|
||||||
|
file_type: "output",
|
||||||
|
provenance: "EXTRACTED",
|
||||||
|
bytes: built.bytes,
|
||||||
|
});
|
||||||
|
for (const [source, contribution] of Object.entries(built.inputs))
|
||||||
|
links.push({
|
||||||
|
source: `input:${local(source)}`,
|
||||||
|
target: id,
|
||||||
|
relation: "bundled-into",
|
||||||
|
confidence: "EXTRACTED",
|
||||||
|
confidence_score: 1,
|
||||||
|
bytes: contribution.bytesInOutput,
|
||||||
|
});
|
||||||
|
for (const imported of built.imports)
|
||||||
|
links.push({
|
||||||
|
source: id,
|
||||||
|
target: output(imported.path, entry),
|
||||||
|
relation: imported.kind,
|
||||||
|
confidence: "EXTRACTED",
|
||||||
|
confidence_score: 1,
|
||||||
|
});
|
||||||
|
if (built.cssBundle)
|
||||||
|
links.push({
|
||||||
|
source: id,
|
||||||
|
target: output(built.cssBundle, entry),
|
||||||
|
relation: "css-bundle",
|
||||||
|
confidence: "EXTRACTED",
|
||||||
|
confidence_score: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { producer: "m3-bun", nodes, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphRecord {
|
||||||
|
path: string;
|
||||||
|
repositoryId: string;
|
||||||
|
runId: string;
|
||||||
|
snapshotId: string;
|
||||||
|
nodes: number;
|
||||||
|
edges: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a finished build: run, duration sample, output artifacts and the module graph snapshot. */
|
||||||
|
export async function recordBuild(
|
||||||
|
report: BuildReport,
|
||||||
|
metafile: BuildMetafile,
|
||||||
|
path = graphPath(report.app),
|
||||||
|
): Promise<GraphRecord> {
|
||||||
|
using registry = await openGraph(path);
|
||||||
|
const { app, manifest } = report;
|
||||||
|
const repository = registry.repository(app.name, "m3-app", app.dir, null, { version: app.version });
|
||||||
|
const runId = registry.startRun("m3-bun.build", process.argv.slice(1), app.dir, {
|
||||||
|
buildId: manifest.buildId,
|
||||||
|
minify: manifest.minify,
|
||||||
|
bun: manifest.bun,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
registry.sample(runId, "build", `bun ${manifest.bun}`, 0, report.ms, manifest.buildId);
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- The registry hashes one artifact at a time from a stream.
|
||||||
|
await registry.artifact(join(report.outdir, file.path), "m3-bun.output", runId, {
|
||||||
|
path: file.path,
|
||||||
|
gzipBytes: file.gzipBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const graph = moduleGraph(metafile, relative(app.dir, app.m3.entry).replaceAll("\\", "/"), app.dir);
|
||||||
|
const hash = sha256(JSON.stringify(graph));
|
||||||
|
const imported = await registry.importGraph(repository, graph, hash);
|
||||||
|
registry.finishRun(runId, 0, `${manifest.files.length} files, build ${manifest.buildId}`);
|
||||||
|
return {
|
||||||
|
path: registry.path,
|
||||||
|
repositoryId: repository,
|
||||||
|
runId,
|
||||||
|
snapshotId: `${repository}:graph:${hash}`,
|
||||||
|
nodes: imported.nodes,
|
||||||
|
edges: imported.edges,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
registry.finishRun(runId, 1, "", String(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `bun:graphx` markdown report of an app's builds. */
|
||||||
|
export async function graphReport(app: M3App, path = graphPath(app)): Promise<string> {
|
||||||
|
using registry = await openGraph(path);
|
||||||
|
const { markdown } = await import("bun:graphx");
|
||||||
|
return await markdown(registry, { repository: repositoryId(app) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inputs and outputs that depend on `file` (relative to the app) in the latest build: its importers, transitively. */
|
||||||
|
export async function dependents(
|
||||||
|
app: M3App,
|
||||||
|
file: string,
|
||||||
|
path = graphPath(app),
|
||||||
|
): Promise<{ label: string; kind: string; depth: number }[]> {
|
||||||
|
using registry = await openGraph(path);
|
||||||
|
const snapshot = registry.getSnapshot(repositoryId(app));
|
||||||
|
if (!snapshot) throw new Error(`m3-bun graph: no build of ${app.name} recorded in ${path} (m3-bun build --graph)`);
|
||||||
|
const node = registry.getNode(`${snapshot.id}:input:${file.replaceAll("\\", "/")}`);
|
||||||
|
if (!node) throw new Error(`m3-bun graph: ${file} is not an input of build snapshot ${snapshot.id}`);
|
||||||
|
const { nodes } = await registry.traverse(node.id, { direction: "incoming", maxDepth: 32 });
|
||||||
|
const bundled = await registry.traverse(node.id, { direction: "outgoing", kind: "bundled-into", maxDepth: 1 });
|
||||||
|
return [...nodes, ...bundled.nodes.filter(n => n.depth > 0)]
|
||||||
|
.filter(n => n.depth > 0)
|
||||||
|
.map(n => ({ label: n.label, kind: n.kind, depth: n.depth }));
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Material Symbols as modules: `import home from "m3:icons/home"` is the SVG markup (24 px, outlined by default),
|
||||||
|
// `m3:icons/rounded/home?fill=1&wght=300` picks a style and variant. The SVGs are read at build time from a local
|
||||||
|
// catalogue (`M3_ICONS_ROOT`, or a `forks/material-design-icons/symbols/web` checkout above the app) or else fetched
|
||||||
|
// once from Google's Material Symbols CDN (`M3_ICONS_ORIGIN`, e.g. https://cdn.aphrody.com) into a disk cache, so no
|
||||||
|
// icon font and no native library ship with the app.
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
export const ICON_STYLES = ["outlined", "rounded", "sharp"] as const;
|
||||||
|
export type IconStyle = (typeof ICON_STYLES)[number];
|
||||||
|
|
||||||
|
const CATALOGUE = "forks/material-design-icons/symbols/web";
|
||||||
|
|
||||||
|
/** The catalogue directory: `M3_ICONS_ROOT`, else the nearest `forks/material-design-icons/symbols/web` above `from`. */
|
||||||
|
export function iconsRoot(from: string = import.meta.dir): string | undefined {
|
||||||
|
const fromEnv = Bun.env.M3_ICONS_ROOT;
|
||||||
|
if (fromEnv) return resolve(fromEnv);
|
||||||
|
let dir = resolve(from);
|
||||||
|
for (;;) {
|
||||||
|
const candidate = join(dir, CATALOGUE);
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) return undefined;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconRequest {
|
||||||
|
name: string;
|
||||||
|
style: IconStyle;
|
||||||
|
fill: boolean;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `<name>`, `<style>/<name>` and the `fill` / `wght` query of an `m3:icons/...` specifier tail. */
|
||||||
|
export function parseIconSpecifier(tail: string): IconRequest {
|
||||||
|
const [pathPart = "", query = ""] = tail.split("?", 2);
|
||||||
|
const parts = pathPart.split("/").filter(Boolean);
|
||||||
|
const name = parts.pop();
|
||||||
|
if (!name || !/^[a-z0-9_]+$/.test(name)) throw new Error(`m3-bun: invalid icon name in m3:icons/${tail}`);
|
||||||
|
const style = (parts.pop() ?? "outlined") as IconStyle;
|
||||||
|
if (!(ICON_STYLES as readonly string[]).includes(style) || parts.length > 0)
|
||||||
|
throw new Error(`m3-bun: m3:icons/${tail}: style must be one of ${ICON_STYLES.join(", ")}`);
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
const weight = Number(params.get("wght") ?? 400);
|
||||||
|
if (![100, 200, 300, 400, 500, 600, 700].includes(weight))
|
||||||
|
throw new Error(`m3-bun: m3:icons/${tail}: wght must be 100..700`);
|
||||||
|
return { name, style, fill: params.get("fill") === "1", weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File name of a variant, as the catalogue spells it (`home_wght300fill1_24px.svg`). */
|
||||||
|
export function iconFile({ name, fill, weight }: IconRequest): string {
|
||||||
|
return `${name}${weight === 400 ? "" : `_wght${weight}`}${fill ? (weight === 400 ? "_fill1" : "fill1") : ""}_24px.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google's variant directory: `default`, `fill1`, `wght300`, `wght300fill1`. */
|
||||||
|
export function iconVariant({ fill, weight }: IconRequest): string {
|
||||||
|
return `${weight === 400 ? "" : `wght${weight}`}${fill ? "fill1" : ""}` || "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CDN the icons come from when there is no local catalogue: `M3_ICONS_ORIGIN`, else Google's. */
|
||||||
|
export const iconsOrigin = (): string => Bun.env.M3_ICONS_ORIGIN ?? "https://fonts.gstatic.com";
|
||||||
|
|
||||||
|
/** The Material Symbols CDN URL of a variant (fonts.gstatic.com layout). */
|
||||||
|
export function iconUrl(request: IconRequest, origin: string = iconsOrigin()): string {
|
||||||
|
return `${origin.replace(/\/$/, "")}/s/i/short-term/release/materialsymbols${request.style}/${request.name}/${iconVariant(request)}/24px.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const iconsCacheDir = (): string =>
|
||||||
|
Bun.env.M3_ICONS_CACHE ?? `${Bun.env.XDG_CACHE_HOME ?? `${Bun.env.HOME ?? "/tmp"}/.cache`}/aphrody/material-symbols`;
|
||||||
|
|
||||||
|
/** The cache file of a variant. The origin's `Bun.hash` names a directory, so two CDNs never share entries. */
|
||||||
|
export function iconCacheFile(request: IconRequest, origin: string = iconsOrigin()): string {
|
||||||
|
const key = Bun.hash.xxHash3(origin.replace(/\/$/, "")).toString(16).padStart(16, "0");
|
||||||
|
return `${iconsCacheDir()}/${key}/${request.style}/${request.name}/${iconVariant(request)}.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SVG markup of an icon: the local catalogue when there is one, else the CDN through the disk cache. */
|
||||||
|
export async function loadIcon(request: IconRequest, root: string | null = iconsRoot() ?? null): Promise<string> {
|
||||||
|
if (root) return readIcon(request, root);
|
||||||
|
const origin = iconsOrigin();
|
||||||
|
const cached = Bun.file(iconCacheFile(request, origin));
|
||||||
|
if (await cached.exists()) return (await cached.text()).trim();
|
||||||
|
const url = iconUrl(request, origin);
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
||||||
|
if (res.status === 404) throw new Error(`m3-bun: no icon ${request.style}/${iconFile(request)} at ${url}`);
|
||||||
|
if (!res.ok) throw new Error(`m3-bun: ${url} answered ${res.status}`);
|
||||||
|
const svg = (await res.text()).trim();
|
||||||
|
if (!svg.startsWith("<svg")) throw new Error(`m3-bun: ${url} is not an SVG`);
|
||||||
|
await Bun.write(cached, svg);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SVG markup of an icon from a local catalogue, or a build error that names the file. */
|
||||||
|
export function readIcon(request: IconRequest, root: string | null = iconsRoot() ?? null): string {
|
||||||
|
if (!root)
|
||||||
|
throw new Error("m3-bun: no local Material Symbols catalogue (M3_ICONS_ROOT); use loadIcon to fetch from the CDN");
|
||||||
|
const file = join(root, request.name, `materialsymbols${request.style}`, iconFile(request));
|
||||||
|
if (!existsSync(file)) throw new Error(`m3-bun: no icon ${request.style}/${iconFile(request)} in ${root}`);
|
||||||
|
return readFileSync(file, "utf8").trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// @aphrody/m3-bun: build, serve, compile, package and deploy configs of M3 apps, with one Bun plugin.
|
||||||
|
export { createM3App, dirSource, embeddedSource, startM3Server, DEFAULT_CSP, cacheControl, pickEncoding, resolvePort } from "./server.ts";
|
||||||
|
export type { FileSource, M3App as M3AppHandle, M3AppOptions, M3ServerOptions } from "./server.ts";
|
||||||
|
export { defineM3Api, jsonl, m3Csrf, normalizeApi, readBody, reply, requestCookies, routesToFetch, withCookies, yaml } from "./api.ts";
|
||||||
|
export type { M3ApiModule, M3Csrf, M3CsrfOptions, M3Handler, M3Methods, M3Request, M3Route, M3Routes } from "./api.ts";
|
||||||
|
export { cspWithNonce, createNonce, injectHtml, resolveTheme } from "./server-html.ts";
|
||||||
|
export type { HtmlInjection, M3ResolvedTheme, M3ThemeMode, M3ThemeOptions } from "./server-html.ts";
|
||||||
|
export { renderMarkdown } from "./server-markdown.ts";
|
||||||
|
export type { M3MarkdownOptions, M3MarkdownPage } from "./server-markdown.ts";
|
||||||
|
export { crawlRoutes, pagePath } from "./server-crawl.ts";
|
||||||
|
export type { M3CrawlOptions } from "./server-crawl.ts";
|
||||||
|
export { publish, realtimeHandlers } from "./server-realtime.ts";
|
||||||
|
export type { M3RealtimeOptions, M3SocketData } from "./server-realtime.ts";
|
||||||
|
export { m3Db } from "./server-db.ts";
|
||||||
|
export type { M3DbOptions } from "./server-db.ts";
|
||||||
|
export { m3Auth } from "./server-auth.ts";
|
||||||
|
export type { M3Auth, M3AuthOptions, M3User } from "./server-auth.ts";
|
||||||
|
export { ensureSecret, m3Secret } from "./server-secrets.ts";
|
||||||
|
export type { M3SecretOptions } from "./server-secrets.ts";
|
||||||
|
export { m3Cache } from "./server-cache.ts";
|
||||||
|
export type { M3Cache, M3CacheOptions } from "./server-cache.ts";
|
||||||
|
export { m3McpRoutes } from "./server-mcp.ts";
|
||||||
|
export type { M3McpOptions } from "./server-mcp.ts";
|
||||||
|
export { agUiRoute } from "./server-agui.ts";
|
||||||
|
export type { M3AgUiAgent } from "./server-agui.ts";
|
||||||
|
export { startNextApp } from "./server-next.ts";
|
||||||
|
export type { M3NextApp, M3NextOptions } from "./server-next.ts";
|
||||||
|
export { m3ServerRoutes } from "./server-m3.ts";
|
||||||
|
export type { M3ServerRoutesOptions } from "./server-m3.ts";
|
||||||
|
export { launchInspected, serveInspectorFrontend } from "./dev-inspect.ts";
|
||||||
|
export type { M3ConsoleEntry, M3ConsoleLevel, M3InspectedException, M3Inspected, M3InspectOptions, M3InspectorFrontend } from "./dev-inspect.ts";
|
||||||
|
export { failingApi, loadDevApi } from "./dev-error.ts";
|
||||||
|
export { COMPILE_TARGETS, compileTarget, defineM3Bun, findWorkspaceRoot, loadApp } from "./config.ts";
|
||||||
|
export type { CompileTargetName, M3App, M3BunConfig, M3BunDeploy } from "./config.ts";
|
||||||
|
export { m3Bun, m3BunExtras } from "./plugin.ts";
|
||||||
|
export type { M3BunPluginOptions } from "./plugin.ts";
|
||||||
|
export { M3_OPTIMIZE_IMPORTS, forkPlugins, lazyPlugin, m3OptimizeImports, recordPlugin } from "./plugins.ts";
|
||||||
|
export type { ForkPluginOptions, LazyPluginSpec, N2BOptions, OxcOptions, RecordedPlugin, SvelteOptions } from "./plugins.ts";
|
||||||
|
export { M3_PACKAGE_MODULES, m3PackagesPlugin } from "./plugins-m3.ts";
|
||||||
|
export type { M3PackagePluginOptions } from "./plugins-m3.ts";
|
||||||
|
export { exportedScheme, m3CssFor, mcuM3Css, nativeM3Css, nativeThemeExport, tsM3Css } from "./theme-css.ts";
|
||||||
|
export type { M3CssEngine } from "./theme-css.ts";
|
||||||
|
export { iconCacheFile, iconsOrigin, iconsRoot, parseIconSpecifier, readIcon } from "./icons.ts";
|
||||||
|
export { buildApp, checkEngine, hoistHtml, listBuild, minifyOptions, readManifest } from "./build.ts";
|
||||||
|
export type { BuildAppOptions, BuildManifest, BuildMemory, BuildReport, MinifyLevel } from "./build.ts";
|
||||||
|
export { dependents, graphPath, graphReport, moduleGraph, openGraph, recordBuild, repositoryId } from "./graph.ts";
|
||||||
|
export type { GraphRecord, ModuleGraph } from "./graph.ts";
|
||||||
|
export { compileApp, defaultOutfile, renderEntry } from "./compile.ts";
|
||||||
|
export type { CompileOptions, CompileReport } from "./compile.ts";
|
||||||
|
export { archiveDir, bunArchiveSupported, buildRootfs, packageApp, parseLdd } from "./package.ts";
|
||||||
|
export type { ArchiveFormat, Archiver, PackageOptions, PackageReport, RootfsReport } from "./package.ts";
|
||||||
|
export { loadTool } from "./tools-load.ts";
|
||||||
|
export { lintApp, oxlintBinary } from "./tools-lint.ts";
|
||||||
|
export type { LintDiagnostic, LintOptions, LintPreset, LintReport } from "./tools-lint.ts";
|
||||||
|
export { migrateApp, migrateNext } from "./tools-migrate.ts";
|
||||||
|
export type { CodemodKind, MigrateFile, MigrateNextOptions, MigrateOptions, MigrateReport } from "./tools-migrate.ts";
|
||||||
|
export { createApp, createTemplates } from "./tools-create.ts";
|
||||||
|
export type { CreateOptions, CreateReport } from "./tools-create.ts";
|
||||||
|
export { designApp } from "./tools-design.ts";
|
||||||
|
export type { DesignOptions, DesignReport } from "./tools-design.ts";
|
||||||
|
export { colorRoles, ogImage } from "./tools-og.ts";
|
||||||
|
export type { OgOptions, OgReport } from "./tools-og.ts";
|
||||||
|
export { buildLibrary } from "./tools-lib.ts";
|
||||||
|
export type { LibraryOptions, LibraryReport } from "./tools-lib.ts";
|
||||||
|
export { launch, runtimeSdkPath, smoke } from "./runtime.ts";
|
||||||
|
export type { LaunchOptions, Launched, SmokeReport } from "./runtime.ts";
|
||||||
|
export { runApp } from "./entry.ts";
|
||||||
|
export { TARGETS, contextFor, generateTargets, getTarget } from "./targets/index.ts";
|
||||||
|
export type { GeneratedTarget, Target, TargetContext } from "./targets/index.ts";
|
||||||
+373
@@ -0,0 +1,373 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun package`: the distributable of an app. Compiles for a target, stages the executable with a README,
|
||||||
|
// the build manifest and checksums (and, on request, the generated deploy configs), archives it
|
||||||
|
// (tar.gz, or zip for Windows; Bun writes both) and writes the archive's SHA-256. `--rootfs` also writes the root filesystem of a
|
||||||
|
// scratch image: the executable and exactly the shared libraries `ldd` lists for it.
|
||||||
|
import { chmod, cp, mkdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
|
import { deflateRawSync } from "node:zlib";
|
||||||
|
import { compileApp, type CompileReport } from "./compile.ts";
|
||||||
|
import { loadApp, type CompileTargetName, type M3App } from "./config.ts";
|
||||||
|
import { generateTargets, contextFor } from "./targets/index.ts";
|
||||||
|
import { sha256 } from "./util.ts";
|
||||||
|
|
||||||
|
/** Absolute library paths from the output of `ldd`: `libc.so.6 => /lib/.../libc.so.6 (0x..)` and `/lib64/ld-linux-x86-64.so.2 (0x..)`. */
|
||||||
|
export function parseLdd(output: string): string[] {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const arrow = /=>\s*(\/\S+)/.exec(line);
|
||||||
|
const bare = /^\s*(\/\S+)\s*\(0x/.exec(line);
|
||||||
|
const path = arrow?.[1] ?? bare?.[1];
|
||||||
|
if (path) paths.add(path);
|
||||||
|
}
|
||||||
|
return [...paths].toSorted();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RootfsReport {
|
||||||
|
dir: string;
|
||||||
|
executable: string;
|
||||||
|
libraries: string[];
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root filesystem of a scratch image: `/app/server` plus its libraries at their own absolute paths. Linux only. */
|
||||||
|
export async function buildRootfs(executable: string, dir: string): Promise<RootfsReport> {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
throw new Error(
|
||||||
|
"m3-bun: --rootfs lists the executable's libraries with ldd and needs a Linux host",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ldd = Bun.spawnSync(["ldd", executable], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const text = ldd.stdout.toString();
|
||||||
|
if (ldd.exitCode !== 0 && !text.includes("=>")) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: ldd cannot read ${executable} (${ldd.stderr.toString().trim() || "not a dynamic executable for this host"}); build the rootfs on a host with the target's libc`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const libraries = parseLdd(text);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
await mkdir(join(dir, "app"), { recursive: true });
|
||||||
|
await cp(executable, join(dir, "app", "server"));
|
||||||
|
await chmod(join(dir, "app", "server"), 0o755);
|
||||||
|
let bytes = Bun.file(executable).size;
|
||||||
|
for (const library of libraries) {
|
||||||
|
const target = join(dir, library);
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Create each library destination before copying and counting its staged bytes.
|
||||||
|
await mkdir(dirname(target), { recursive: true });
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Stage libraries serially to bound disk work and stop on the first failed copy.
|
||||||
|
await cp(library, target, { dereference: true });
|
||||||
|
bytes += Bun.file(target).size;
|
||||||
|
}
|
||||||
|
return { dir, executable: join(dir, "app", "server"), libraries, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArchiveFormat = "tar.gz" | "zip";
|
||||||
|
|
||||||
|
interface ArchiveEntry {
|
||||||
|
/** POSIX path relative to the staging root, `/`-terminated for directories. */
|
||||||
|
name: string;
|
||||||
|
mode: number;
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Windows has no execute bit: a file is executable when it starts like a program (shebang, PE, ELF, Mach-O). */
|
||||||
|
function looksExecutable(data: Uint8Array): boolean {
|
||||||
|
const magic = (bytes: number[]) => bytes.every((byte, index) => data[index] === byte);
|
||||||
|
return (
|
||||||
|
magic([0x23, 0x21]) ||
|
||||||
|
magic([0x4d, 0x5a]) ||
|
||||||
|
magic([0x7f, 0x45, 0x4c, 0x46]) ||
|
||||||
|
magic([0xcf, 0xfa, 0xed, 0xfe]) ||
|
||||||
|
magic([0xca, 0xfe, 0xba, 0xbe])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every directory and file under `staging`, sorted by name, with POSIX modes. */
|
||||||
|
async function archiveEntries(staging: string): Promise<ArchiveEntry[]> {
|
||||||
|
const paths = await Array.fromAsync(
|
||||||
|
new Bun.Glob("**/*").scan({ cwd: staging, onlyFiles: false, dot: true }),
|
||||||
|
);
|
||||||
|
const entries: ArchiveEntry[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
const name = path.replaceAll("\\", "/");
|
||||||
|
const full = join(staging, path);
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- One stat per staged path, in a bounded directory.
|
||||||
|
const info = await stat(full);
|
||||||
|
if (info.isDirectory()) {
|
||||||
|
entries.push({ name: `${name}/`, mode: 0o755, data: new Uint8Array() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Staged files are read one at a time to bound memory.
|
||||||
|
const data = new Uint8Array(await Bun.file(full).arrayBuffer());
|
||||||
|
const mode =
|
||||||
|
process.platform === "win32" ? (looksExecutable(data) ? 0o755 : 0o644) : info.mode & 0o777;
|
||||||
|
entries.push({ name, mode, data });
|
||||||
|
}
|
||||||
|
return entries.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/** ustar header: owner 0:0, mtime 0, so the same files give the same bytes on every host. */
|
||||||
|
function tarHeader(name: string, mode: number, size: number, type: "0" | "5"): Uint8Array {
|
||||||
|
const header = new Uint8Array(512);
|
||||||
|
const put = (offset: number, length: number, value: string) =>
|
||||||
|
header.set(encoder.encode(value).subarray(0, length), offset);
|
||||||
|
const octal = (offset: number, length: number, value: number) =>
|
||||||
|
put(offset, length - 1, value.toString(8).padStart(length - 1, "0"));
|
||||||
|
let prefix = "";
|
||||||
|
let base = name;
|
||||||
|
if (encoder.encode(name).length > 100) {
|
||||||
|
const cut = name.lastIndexOf("/", name.length - 2);
|
||||||
|
prefix = name.slice(0, cut);
|
||||||
|
base = name.slice(cut + 1);
|
||||||
|
if (cut < 0 || encoder.encode(base).length > 100 || encoder.encode(prefix).length > 155) {
|
||||||
|
throw new Error(`m3-bun: path too long for a tar archive: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(0, 100, base);
|
||||||
|
octal(100, 8, mode);
|
||||||
|
octal(108, 8, 0);
|
||||||
|
octal(116, 8, 0);
|
||||||
|
octal(124, 12, size);
|
||||||
|
octal(136, 12, 0);
|
||||||
|
header.fill(0x20, 148, 156);
|
||||||
|
put(156, 1, type);
|
||||||
|
put(257, 6, "ustar");
|
||||||
|
put(263, 2, "00");
|
||||||
|
put(345, 155, prefix);
|
||||||
|
const sum = header.reduce((total, byte) => total + byte, 0);
|
||||||
|
put(148, 7, `${sum.toString(8).padStart(6, "0")}\0`);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarGz(entries: ArchiveEntry[]): Uint8Array {
|
||||||
|
const chunks: Uint8Array[] = [tarHeader("./", 0o755, 0, "5")];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const directory = entry.name.endsWith("/");
|
||||||
|
chunks.push(tarHeader(`./${entry.name}`, entry.mode, entry.data.length, directory ? "5" : "0"));
|
||||||
|
if (directory) continue;
|
||||||
|
chunks.push(entry.data);
|
||||||
|
const pad = (512 - (entry.data.length % 512)) % 512;
|
||||||
|
if (pad) chunks.push(new Uint8Array(pad));
|
||||||
|
}
|
||||||
|
chunks.push(new Uint8Array(1024));
|
||||||
|
return Bun.gzipSync(Buffer.concat(chunks), { level: 9 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zip with Unix modes in the external attributes and a fixed 1980-01-01 timestamp. */
|
||||||
|
function zip(entries: ArchiveEntry[]): Uint8Array {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const central: Uint8Array[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const directory = entry.name.endsWith("/");
|
||||||
|
const name = encoder.encode(entry.name);
|
||||||
|
const deflated = directory ? entry.data : deflateRawSync(entry.data, { level: 9 });
|
||||||
|
const method = directory || deflated.length >= entry.data.length ? 0 : 8;
|
||||||
|
const body = method === 8 ? deflated : entry.data;
|
||||||
|
const crc = Bun.hash.crc32(entry.data) >>> 0;
|
||||||
|
const head = Buffer.alloc(30);
|
||||||
|
head.writeUInt32LE(0x04034b50, 0);
|
||||||
|
head.writeUInt16LE(20, 4);
|
||||||
|
head.writeUInt16LE(0x0800, 6);
|
||||||
|
head.writeUInt16LE(method, 8);
|
||||||
|
head.writeUInt16LE(0, 10);
|
||||||
|
head.writeUInt16LE(0x21, 12);
|
||||||
|
head.writeUInt32LE(crc, 14);
|
||||||
|
head.writeUInt32LE(body.length, 18);
|
||||||
|
head.writeUInt32LE(entry.data.length, 22);
|
||||||
|
head.writeUInt16LE(name.length, 26);
|
||||||
|
chunks.push(head, name, body);
|
||||||
|
const unixMode = (directory ? 0o40000 : 0o100000) | entry.mode;
|
||||||
|
const record = Buffer.alloc(46);
|
||||||
|
record.writeUInt32LE(0x02014b50, 0);
|
||||||
|
record.writeUInt16LE((3 << 8) | 20, 4);
|
||||||
|
record.writeUInt16LE(20, 6);
|
||||||
|
record.writeUInt16LE(0x0800, 8);
|
||||||
|
record.writeUInt16LE(method, 10);
|
||||||
|
record.writeUInt16LE(0, 12);
|
||||||
|
record.writeUInt16LE(0x21, 14);
|
||||||
|
record.writeUInt32LE(crc, 16);
|
||||||
|
record.writeUInt32LE(body.length, 20);
|
||||||
|
record.writeUInt32LE(entry.data.length, 24);
|
||||||
|
record.writeUInt16LE(name.length, 28);
|
||||||
|
record.writeUInt32LE((unixMode * 0x10000 + (directory ? 0x10 : 0)) >>> 0, 38);
|
||||||
|
record.writeUInt32LE(offset, 42);
|
||||||
|
central.push(record, name);
|
||||||
|
offset += head.length + name.length + body.length;
|
||||||
|
}
|
||||||
|
const size = central.reduce((total, chunk) => total + chunk.length, 0);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
end.writeUInt16LE(entries.length, 8);
|
||||||
|
end.writeUInt16LE(entries.length, 10);
|
||||||
|
end.writeUInt32LE(size, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
return Buffer.concat([...chunks, ...central, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who wrote an archive: `Bun.Archive`, or the writers above on a runtime whose `Bun.Archive` lacks modes and zip. */
|
||||||
|
export type Archiver = "bun-archive" | "m3-bun";
|
||||||
|
|
||||||
|
let archiveSupport: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
/** `Bun.Archive` with per-entry modes, a fixed `mtime` and zip output (the aphrody-labs fork; 1.4.3-aphrody.2 lacks them). */
|
||||||
|
export function bunArchiveSupported(): Promise<boolean> {
|
||||||
|
archiveSupport ??= (async () => {
|
||||||
|
if (typeof Bun.Archive !== "function") return false;
|
||||||
|
try {
|
||||||
|
const bytes = await new Bun.Archive(
|
||||||
|
{ x: { data: "x", mode: 0o755 } },
|
||||||
|
{ format: "zip", mtime: 0 },
|
||||||
|
).bytes();
|
||||||
|
const files = await new Bun.Archive(bytes).files();
|
||||||
|
return bytes[0] === 0x50 && bytes[1] === 0x4b && (await files.get("x")?.text()) === "x";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return archiveSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive the contents of `staging` into `archive`. Bun writes it (`Bun.Archive` when the runtime has the full
|
||||||
|
* contract), so no `tar` or `zip` is needed, and the bytes depend only on the names, contents and modes of the files.
|
||||||
|
*/
|
||||||
|
export async function archiveDir(
|
||||||
|
staging: string,
|
||||||
|
archive: string,
|
||||||
|
format: ArchiveFormat,
|
||||||
|
): Promise<Archiver> {
|
||||||
|
await mkdir(dirname(archive), { recursive: true });
|
||||||
|
await rm(archive, { force: true });
|
||||||
|
const entries = await archiveEntries(staging);
|
||||||
|
if (await bunArchiveSupported()) {
|
||||||
|
const files = Object.fromEntries(
|
||||||
|
entries
|
||||||
|
.filter((entry) => !entry.name.endsWith("/"))
|
||||||
|
.map((entry) => [entry.name, { data: entry.data, mode: entry.mode }]),
|
||||||
|
);
|
||||||
|
// `bytes()` applies `compress`; `Bun.write(path, archive)` writes the uncompressed tar store.
|
||||||
|
await Bun.write(
|
||||||
|
archive,
|
||||||
|
await new Bun.Archive(
|
||||||
|
files,
|
||||||
|
format === "zip" ? { format: "zip", mtime: 0 } : { compress: "gzip", level: 9, mtime: 0 },
|
||||||
|
).bytes(),
|
||||||
|
);
|
||||||
|
return "bun-archive";
|
||||||
|
}
|
||||||
|
await Bun.write(archive, format === "zip" ? zip(entries) : tarGz(entries));
|
||||||
|
return "m3-bun";
|
||||||
|
}
|
||||||
|
export interface PackageOptions {
|
||||||
|
dir?: string;
|
||||||
|
configFile?: string;
|
||||||
|
target?: CompileTargetName;
|
||||||
|
/** Archive format (default zip for Windows targets, else tar.gz). */
|
||||||
|
format?: ArchiveFormat;
|
||||||
|
/** Output directory (default `<app>/release`). */
|
||||||
|
out?: string;
|
||||||
|
/** Also write `<out>/rootfs` for a scratch image (Linux glibc targets, on a Linux host). */
|
||||||
|
rootfs?: boolean;
|
||||||
|
/** Include the generated deploy configs under `deploy/` in the archive. */
|
||||||
|
deploy?: boolean;
|
||||||
|
/** Reuse an executable instead of compiling. */
|
||||||
|
executable?: string;
|
||||||
|
app?: M3App;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageReport {
|
||||||
|
archive: string;
|
||||||
|
bytes: number;
|
||||||
|
sha256: string;
|
||||||
|
checksumFile: string;
|
||||||
|
/** `Bun.Archive` or the built-in writers. */
|
||||||
|
archiver: Archiver;
|
||||||
|
files: string[];
|
||||||
|
compile?: CompileReport;
|
||||||
|
rootfs?: RootfsReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readmeText = (name: string, version: string, exe: string, port: number) => `${name} ${version}
|
||||||
|
|
||||||
|
One executable serves the UI and the API from the same process.
|
||||||
|
|
||||||
|
${exe} serve on $PORT (default ${port})
|
||||||
|
${exe} --port 8080 choose the port
|
||||||
|
${exe} --healthcheck exit 0 when the running instance answers /healthz
|
||||||
|
${exe} --version
|
||||||
|
|
||||||
|
Endpoints: / (UI), /healthz, /api/m3/info and the app's own /api/* routes.
|
||||||
|
Verify the download with SHA256SUMS. Built with @aphrody/m3-bun; the executable embeds the Bun runtime.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Compile, stage and archive an app. */
|
||||||
|
export async function packageApp(options: PackageOptions = {}): Promise<PackageReport> {
|
||||||
|
const app =
|
||||||
|
options.app ?? (await loadApp(resolve(options.dir ?? process.cwd()), options.configFile));
|
||||||
|
const target = options.target ?? app.bun.compile?.target;
|
||||||
|
const windows = (target ?? "").startsWith("windows");
|
||||||
|
const format = options.format ?? (windows ? "zip" : "tar.gz");
|
||||||
|
const out = resolve(options.out ?? join(app.dir, "release"));
|
||||||
|
const exeName = `${app.name}${windows ? ".exe" : ""}`;
|
||||||
|
const stem = `${app.name}-${app.version}-${target ?? `${process.platform}-${process.arch}`}`;
|
||||||
|
const staging = join(out, ".staging", stem);
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
await mkdir(staging, { recursive: true });
|
||||||
|
|
||||||
|
let compile: CompileReport | undefined;
|
||||||
|
let executable = options.executable ? resolve(options.executable) : undefined;
|
||||||
|
if (!executable) {
|
||||||
|
compile = await compileApp({
|
||||||
|
app,
|
||||||
|
...(target ? { target } : {}),
|
||||||
|
outfile: join(staging, exeName),
|
||||||
|
});
|
||||||
|
executable = compile.outfile;
|
||||||
|
} else {
|
||||||
|
await cp(executable, join(staging, exeName));
|
||||||
|
executable = join(staging, exeName);
|
||||||
|
}
|
||||||
|
await chmod(executable, 0o755).catch(() => undefined);
|
||||||
|
|
||||||
|
const manifest = Bun.file(join(app.outdir, "m3-bun.manifest.json"));
|
||||||
|
if (await manifest.exists()) await Bun.write(join(staging, "m3-bun.manifest.json"), manifest);
|
||||||
|
await Bun.write(
|
||||||
|
join(staging, "README.txt"),
|
||||||
|
readmeText(app.name, app.version, windows ? exeName : `./${exeName}`, app.port),
|
||||||
|
);
|
||||||
|
if (options.deploy) await generateTargets(["all"], contextFor(app), join(staging, "deploy"));
|
||||||
|
const exeHash = sha256(new Uint8Array(await Bun.file(executable).arrayBuffer()));
|
||||||
|
await Bun.write(join(staging, "SHA256SUMS"), `${exeHash} ${exeName}\n`);
|
||||||
|
|
||||||
|
let rootfs: RootfsReport | undefined;
|
||||||
|
if (options.rootfs) rootfs = await buildRootfs(executable, join(out, "rootfs"));
|
||||||
|
|
||||||
|
const archive = join(out, `${stem}.${format}`);
|
||||||
|
const archiver = await archiveDir(staging, archive, format);
|
||||||
|
const archiveHash = sha256(new Uint8Array(await Bun.file(archive).arrayBuffer()));
|
||||||
|
const checksumFile = `${archive}.sha256`;
|
||||||
|
await Bun.write(checksumFile, `${archiveHash} ${basename(archive)}\n`);
|
||||||
|
const files = (
|
||||||
|
await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: staging, onlyFiles: true, dot: true }))
|
||||||
|
)
|
||||||
|
.map((file) => file.replaceAll("\\", "/"))
|
||||||
|
.toSorted();
|
||||||
|
await rm(join(out, ".staging"), { recursive: true, force: true });
|
||||||
|
return {
|
||||||
|
archive,
|
||||||
|
bytes: Bun.file(archive).size,
|
||||||
|
sha256: archiveHash,
|
||||||
|
checksumFile,
|
||||||
|
archiver,
|
||||||
|
files,
|
||||||
|
...(compile ? { compile } : {}),
|
||||||
|
...(rootfs ? { rootfs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The one Bun plugin of an M3 app. It is `@aphrody/m3`'s plugin (Tailwind v4, Sass, `m3:*` token sheets, `?url`
|
||||||
|
// imports) plus what makes it work in a standalone checkout:
|
||||||
|
// - canonical native theme artifacts and the Rust `@aphrody/bun` generator through `m3Plugin`,
|
||||||
|
// with caller-provided `css` overrides preserved,
|
||||||
|
// - `m3:fonts.css`, `m3:fonts-jp.css`, `m3:fonts-symbols.css` from `@aphrody/m3-fonts` when no Aphrody OS checkout
|
||||||
|
// holds the fonts,
|
||||||
|
// - the m3 packages as modules (src/plugins-m3.ts): `m3:icons/[style/]name[?fill=1&wght=300]` (Material Symbols SVG
|
||||||
|
// markup from the catalogue, @aphrody/m3-icons native rendering or the CDN), `m3:icon/<style>/<name>`
|
||||||
|
// (@aphrody/m3-icons/plugin), `m3:theme/*.css` and `m3:assets/<path>[?url]`,
|
||||||
|
// - the fork plugins (src/plugins.ts): Svelte (on), YAML, MDX, n2b and Oxc (opt-in), each imported on first use,
|
||||||
|
// - `m3:optional/<specifier>`: a module that may not be installed. `import brand, { resolved } from
|
||||||
|
// "m3:optional/@aphrody/nie-ui"` is the module namespace and `resolved: true` when the specifier resolves
|
||||||
|
// from the app, `null` and `resolved: false` when it does not (host-bound packages outside the workspace).
|
||||||
|
//
|
||||||
|
// bunfig.toml [serve.static] plugins = ["@aphrody/m3-bun/plugin"]
|
||||||
|
// Bun.build / compile plugins: [m3Bun({ theme: { seed: "#6750a4" } })]
|
||||||
|
import { m3Plugin, type M3PluginOptions } from "@aphrody/m3/plugin";
|
||||||
|
import type { BunPlugin } from "bun";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { forkPlugins, type ForkPluginOptions } from "./plugins.ts";
|
||||||
|
import { m3PackagesPlugin } from "./plugins-m3.ts";
|
||||||
|
|
||||||
|
const FONT_SHEETS: Record<string, string> = {
|
||||||
|
"m3:fonts.css": "@aphrody/m3-fonts/fonts.css",
|
||||||
|
"m3:fonts-jp.css": "@aphrody/m3-fonts/fonts-jp.css",
|
||||||
|
"m3:fonts-symbols.css": "@aphrody/m3-fonts/fonts-symbols.css",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONAL_NAMESPACE = "m3-optional";
|
||||||
|
|
||||||
|
export interface M3BunPluginOptions extends M3PluginOptions, ForkPluginOptions {
|
||||||
|
/** Directory of the Material Symbols SVG catalogue (default: `M3_ICONS_ROOT`, then @aphrody/material-design-icons). */
|
||||||
|
iconsRoot?: string;
|
||||||
|
/** Directory containing the application or config (default: process.cwd()). */
|
||||||
|
configDir?: string;
|
||||||
|
/** Optional theme override */
|
||||||
|
theme?: M3PluginOptions["theme"];
|
||||||
|
/** Optional css generator override */
|
||||||
|
css?: M3PluginOptions["css"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `m3Bun` adds to `m3Plugin` besides the fork plugins: the m3 package modules, `m3:optional/` and the standalone
|
||||||
|
* font sheets. Exported for `Bun.build` callers that compose themselves.
|
||||||
|
*/
|
||||||
|
export function m3BunExtras(options: Pick<M3BunPluginOptions, "iconsRoot" | "configDir"> = {}): BunPlugin {
|
||||||
|
const packages = m3PackagesPlugin(options);
|
||||||
|
return {
|
||||||
|
name: "@aphrody/m3-bun",
|
||||||
|
setup(build) {
|
||||||
|
void packages.setup(build);
|
||||||
|
build.onResolve({ filter: /^m3:optional\// }, args => ({
|
||||||
|
path: args.path.slice("m3:optional/".length),
|
||||||
|
namespace: OPTIONAL_NAMESPACE,
|
||||||
|
}));
|
||||||
|
build.onLoad({ filter: /.*/, namespace: OPTIONAL_NAMESPACE }, args => {
|
||||||
|
try {
|
||||||
|
const resolved = Bun.resolveSync(args.path, options.configDir ?? process.cwd());
|
||||||
|
return {
|
||||||
|
contents: `import * as namespace from ${JSON.stringify(resolved)};\nexport default namespace;\nexport const resolved = true;\n`,
|
||||||
|
loader: "js",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
contents: "export default null;\nexport const resolved = false;\n",
|
||||||
|
loader: "js",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// m3Plugin serves the font sheets from an Aphrody OS checkout; without one, `@aphrody/m3-fonts` does.
|
||||||
|
build.onResolve({ filter: /^m3:fonts[-a-z]*\.css$/ }, args => {
|
||||||
|
const specifier = FONT_SHEETS[args.path];
|
||||||
|
if (!specifier) return undefined;
|
||||||
|
try {
|
||||||
|
return { path: Bun.resolveSync(specifier, options.configDir ?? process.cwd()) };
|
||||||
|
} catch {
|
||||||
|
return { path: Bun.resolveSync(specifier, dirname(import.meta.path)) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete plugin. */
|
||||||
|
export function m3Bun(options: M3BunPluginOptions = {}): BunPlugin {
|
||||||
|
const { iconsRoot: icons, yaml, svelte, mdx, n2b, oxc, ...m3Options } = options;
|
||||||
|
const extras = m3BunExtras({
|
||||||
|
...(icons ? { iconsRoot: icons } : {}),
|
||||||
|
...(options.configDir ? { configDir: options.configDir } : {}),
|
||||||
|
});
|
||||||
|
const forks = forkPlugins({ yaml, svelte, mdx, n2b, oxc }, options.configDir);
|
||||||
|
const base = m3Plugin(m3Options);
|
||||||
|
return {
|
||||||
|
name: "@aphrody/m3-bun",
|
||||||
|
setup(build) {
|
||||||
|
// Extras first: they decide before m3Plugin throws on an `m3:` module or a font sheet it does not know.
|
||||||
|
void extras.setup(build);
|
||||||
|
// Only the native-hook plugins (mdx, oxc `native`) set up asynchronously; the default plugin stays synchronous.
|
||||||
|
const pending = forks.map(plugin => plugin.setup(build)).filter(result => result instanceof Promise);
|
||||||
|
void base.setup(build);
|
||||||
|
if (pending.length > 0) return Promise.all(pending).then(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For `bunfig.toml`: the plugin with its defaults, theme read from the `m3.config.ts` of the working directory. */
|
||||||
|
const plugin: BunPlugin = m3Bun();
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The m3 packages `m3Bun` serves as modules (resolved from the app, then from @aphrody/m3-bun):
|
||||||
|
// m3:icons/[style/]name[?fill=1&wght=300] Material Symbols SVG markup: the local catalogue (`iconsRoot`,
|
||||||
|
// `M3_ICONS_ROOT`, else @aphrody/material-design-icons), else
|
||||||
|
// @aphrody/m3-icons rendered natively, else the CDN (src/icons.ts)
|
||||||
|
// m3:icon/<style>/<name>?fill&wght&grad&opsz&size
|
||||||
|
// @aphrody/m3-icons/plugin as is: every axis, native rendering only
|
||||||
|
// m3:theme/{tokens,tailwind,spaceui}.css @aphrody/m3-theme sheets (baseline scheme, seed #6750a4), each
|
||||||
|
// compiled as its own Tailwind root by m3Plugin
|
||||||
|
// m3:assets/<path>[?url] @aphrody/assets files; `?url` follows @aphrody/m3's `?url` rules
|
||||||
|
import type { BunPlugin, OnLoadArgs, OnLoadResult, OnResolveArgs } from "bun";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { loadIcon, parseIconSpecifier, readIcon, type IconRequest } from "./icons.ts";
|
||||||
|
import { lazyPlugin, recordPlugin, resolveDirs, resolveFrom, type RecordedPlugin } from "./plugins.ts";
|
||||||
|
|
||||||
|
const ICONS_NAMESPACE = "m3-icons";
|
||||||
|
const ASSET_URL_NAMESPACE = "m3-bun-asset-url";
|
||||||
|
|
||||||
|
/** `m3:<alias>/` -> package whose files it serves. */
|
||||||
|
export const M3_PACKAGE_MODULES: Readonly<Record<string, string>> = {
|
||||||
|
theme: "@aphrody/m3-theme",
|
||||||
|
assets: "@aphrody/assets",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface M3PackagePluginOptions {
|
||||||
|
/** Directory containing the application (default: process.cwd()). */
|
||||||
|
configDir?: string;
|
||||||
|
/** Material Symbols catalogue directory; default `M3_ICONS_ROOT`, then @aphrody/material-design-icons. */
|
||||||
|
iconsRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SymbolCatalogue {
|
||||||
|
hasSymbol(name: string, style: IconRequest["style"], weight: number, filled: boolean): boolean;
|
||||||
|
readSymbolSvg(name: string, style: IconRequest["style"], weight: number, filled: boolean): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgModule = (svg: string): OnLoadResult => ({
|
||||||
|
contents: `export default ${JSON.stringify(svg)};\n`,
|
||||||
|
loader: "js",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `m3IconPlugin` of @aphrody/m3-icons, once its native library is known to load. */
|
||||||
|
function nativeIcons(dirs: readonly string[]): () => Promise<BunPlugin> {
|
||||||
|
let loaded: Promise<BunPlugin> | undefined;
|
||||||
|
return () =>
|
||||||
|
(loaded ??= (async () => {
|
||||||
|
const path = resolveFrom("@aphrody/m3-icons/plugin", dirs);
|
||||||
|
if (!path) throw new Error("m3-bun: native Material Symbols need @aphrody/m3-icons (bun add @aphrody/m3-icons)");
|
||||||
|
const ffi = await import(Bun.resolveSync("@aphrody/bun", dirname(path)));
|
||||||
|
if (typeof ffi.isAphrodyFfiAvailable === "function" && !ffi.isAphrodyFfiAvailable())
|
||||||
|
throw new Error(
|
||||||
|
"m3-bun: @aphrody/m3-icons renders through the @aphrody/bun native library, which is not available here",
|
||||||
|
);
|
||||||
|
return ((await import(path)) as { m3IconPlugin: BunPlugin }).m3IconPlugin;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderNative(plugin: RecordedPlugin, specifier: string): Promise<OnLoadResult> {
|
||||||
|
const resolved = await plugin.resolve({
|
||||||
|
path: specifier,
|
||||||
|
importer: "",
|
||||||
|
namespace: "file",
|
||||||
|
kind: "import-statement",
|
||||||
|
} as OnResolveArgs);
|
||||||
|
if (!resolved?.path) throw new Error(`m3-bun: @aphrody/m3-icons does not resolve ${specifier}`);
|
||||||
|
const result = await plugin.load({
|
||||||
|
path: resolved.path,
|
||||||
|
namespace: resolved.namespace ?? "file",
|
||||||
|
loader: "js",
|
||||||
|
} as OnLoadArgs);
|
||||||
|
if (!result) throw new Error(`m3-bun: @aphrody/m3-icons does not load ${specifier}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material Symbols (`m3:icons/`, `m3:icon/`) and the m3 package files (`m3:theme/`, `m3:assets/`). */
|
||||||
|
export function m3PackagesPlugin(options: M3PackagePluginOptions = {}): BunPlugin {
|
||||||
|
const dirs = resolveDirs(options.configDir);
|
||||||
|
const native = nativeIcons(dirs);
|
||||||
|
let recorded: Promise<RecordedPlugin> | undefined;
|
||||||
|
const getRecorded = () => (recorded ??= native().then(plugin => recordPlugin(plugin)));
|
||||||
|
let catalogue: Promise<SymbolCatalogue | null> | undefined;
|
||||||
|
const getCatalogue = () =>
|
||||||
|
(catalogue ??= (async () => {
|
||||||
|
const path = resolveFrom("@aphrody/material-design-icons", dirs);
|
||||||
|
return path ? ((await import(path)) as SymbolCatalogue) : null;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const symbol = async (tail: string): Promise<OnLoadResult> => {
|
||||||
|
const request = parseIconSpecifier(tail);
|
||||||
|
const root = options.iconsRoot ?? Bun.env.M3_ICONS_ROOT;
|
||||||
|
if (root) return svgModule(readIcon(request, resolve(root)));
|
||||||
|
const symbols = await getCatalogue();
|
||||||
|
const { name, style, weight, fill } = request;
|
||||||
|
if (symbols?.hasSymbol(name, style, weight, fill))
|
||||||
|
return svgModule(symbols.readSymbolSvg(name, style, weight, fill).trim());
|
||||||
|
const rendered = await getRecorded().then(
|
||||||
|
plugin => renderNative(plugin, `m3:icon/${style}/${name}?fill=${fill ? 1 : 0}&wght=${weight}`),
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return rendered ?? svgModule(await loadIcon(request, null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageIcons = lazyPlugin({
|
||||||
|
name: "m3-bun:m3-icons",
|
||||||
|
load: () =>
|
||||||
|
native().catch((error: Error) => {
|
||||||
|
throw new Error(`${error.message}; m3:icons/[style/]name serves the SVG catalogue without it`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onResolve: [{ filter: /^m3:icon\// }],
|
||||||
|
onLoad: [{ filter: /.*/, namespace: "m3-icon" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "@aphrody/m3-bun:packages",
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^m3:icons\// }, args => ({
|
||||||
|
path: args.path.slice("m3:icons/".length),
|
||||||
|
namespace: ICONS_NAMESPACE,
|
||||||
|
}));
|
||||||
|
build.onLoad({ filter: /.*/, namespace: ICONS_NAMESPACE }, args => symbol(args.path));
|
||||||
|
void packageIcons.setup(build);
|
||||||
|
|
||||||
|
build.onResolve({ filter: /^m3:(?:theme|assets)\// }, async args => {
|
||||||
|
const [specifier = "", query] = args.path.split("?", 2);
|
||||||
|
const alias = specifier.slice("m3:".length, specifier.indexOf("/"));
|
||||||
|
const pkg = M3_PACKAGE_MODULES[alias]!;
|
||||||
|
const target = `${pkg}${specifier.slice(specifier.indexOf("/"))}`;
|
||||||
|
const path = resolveFrom(target, dirs);
|
||||||
|
if (!path) throw new Error(`m3-bun: ${args.path} needs ${target}, which does not resolve (bun add ${pkg})`);
|
||||||
|
if (query === undefined) return { path };
|
||||||
|
if (query !== "url" || alias !== "assets")
|
||||||
|
throw new Error(`m3-bun: ${args.path}: only m3:assets/ takes a query, and only ?url`);
|
||||||
|
const { urlImportForm } = await import("@aphrody/m3");
|
||||||
|
if (urlImportForm(path) === "file") return { path };
|
||||||
|
// The suffix keeps this module apart from a plain import of the same file (Bun deduplicates by path).
|
||||||
|
return { path: `${path}?url`, namespace: ASSET_URL_NAMESPACE };
|
||||||
|
});
|
||||||
|
build.onLoad({ filter: /.*/, namespace: ASSET_URL_NAMESPACE }, async args => {
|
||||||
|
const { dataUrlFor } = await import("@aphrody/m3");
|
||||||
|
const url = await dataUrlFor(args.path.slice(0, -"?url".length));
|
||||||
|
return { contents: `export default ${JSON.stringify(url)};\n`, loader: "js" };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The Bun plugins of the Aphrody fork that `m3Bun` composes, each behind an option of `M3BunPluginOptions`:
|
||||||
|
// yaml @aphrody/bun-plugin-yaml .yaml/.yml through js-yaml default off: Bun loads YAML natively
|
||||||
|
// svelte @aphrody/bun-plugin-svelte .svelte, .svelte.{ts,js} default on, imported on the first Svelte module
|
||||||
|
// mdx @aphrody/bun-mdx-rs .mdx through mdxjs-rs (onBeforeParse) default off: a native hook loads with the build
|
||||||
|
// n2b @aphrody/bun-plugin-n2b Node→Bun report or codemods default off
|
||||||
|
// oxc @aphrody/bun-plugin-oxc Oxc transform, lint, isolated .d.ts default off
|
||||||
|
// Packages resolve from the app directory first, then from @aphrody/m3-bun. A plugin is imported on the first module
|
||||||
|
// its hooks match (`lazyPlugin`); a missing package or a failing import is a build error that names the option.
|
||||||
|
// Native `onBeforeParse` hooks (mdx, oxc `native: true`) must be registered during setup: Bun ignores later ones.
|
||||||
|
import type {
|
||||||
|
BuildOutput,
|
||||||
|
BunPlugin,
|
||||||
|
OnLoadArgs,
|
||||||
|
OnLoadResult,
|
||||||
|
OnResolveArgs,
|
||||||
|
OnResolveResult,
|
||||||
|
PluginBuilder,
|
||||||
|
} from "bun";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
/** m3 barrels whose unused re-exports `Bun.build` skips (`optimizeImports`). */
|
||||||
|
export const M3_OPTIMIZE_IMPORTS: readonly string[] = [
|
||||||
|
"@aphrody/m3-react",
|
||||||
|
"@aphrody/m3-primitives",
|
||||||
|
"@aphrody/m3-baseui",
|
||||||
|
"@aphrody/m3-ai",
|
||||||
|
"@aphrody/m3-explorer",
|
||||||
|
"@aphrody/m3-forms",
|
||||||
|
"@aphrody/m3-reader",
|
||||||
|
"@aphrody/m3-icons",
|
||||||
|
"@aphrody/m3-motion",
|
||||||
|
"@aphrody/app-ui",
|
||||||
|
"@aphrody/identity",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** `optimizeImports` of an m3 build: the m3 barrels, then the app's own entries. */
|
||||||
|
export function m3OptimizeImports(extra: readonly string[] = []): string[] {
|
||||||
|
return [...new Set([...M3_OPTIMIZE_IMPORTS, ...extra])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directories a composed package resolves from: the app, then this package. */
|
||||||
|
export function resolveDirs(configDir: string = process.cwd()): string[] {
|
||||||
|
return [configDir, import.meta.dir];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute path of `specifier` from the first directory that resolves it. */
|
||||||
|
export function resolveFrom(specifier: string, dirs: readonly string[]): string | undefined {
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
return Bun.resolveSync(specifier, dir);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import `specifier` for `feature`, or throw an error that says what to install. */
|
||||||
|
export async function importFrom<T = Record<string, any>>(
|
||||||
|
specifier: string,
|
||||||
|
dirs: readonly string[],
|
||||||
|
feature: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const path = resolveFrom(specifier, dirs);
|
||||||
|
if (!path)
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: ${feature} needs ${specifier}, which resolves neither from ${dirs[0]} nor from @aphrody/m3-bun (bun add ${packageName(specifier)})`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return (await import(path)) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`m3-bun: ${feature}: importing ${specifier} failed: ${(error as Error).message}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageName(specifier: string): string {
|
||||||
|
const parts = specifier.split("/");
|
||||||
|
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hook {
|
||||||
|
filter: RegExp;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Handler<A, R> extends Hook {
|
||||||
|
callback: (args: A) => R | Promise<R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hooks a plugin registered, replayable on any module. */
|
||||||
|
export interface RecordedPlugin {
|
||||||
|
load(args: OnLoadArgs): Promise<OnLoadResult>;
|
||||||
|
resolve(args: OnResolveArgs): Promise<OnResolveResult | undefined>;
|
||||||
|
end(result: BuildOutput): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = (hook: Hook, path: string, namespace: string | undefined) =>
|
||||||
|
(hook.namespace ?? "file") === (namespace || "file") && hook.filter.test(path);
|
||||||
|
|
||||||
|
/** Run `plugin.setup` against a recording builder; native hooks are refused (they only work during setup). */
|
||||||
|
export async function recordPlugin(plugin: BunPlugin, config?: PluginBuilder["config"]): Promise<RecordedPlugin> {
|
||||||
|
const loads: Handler<OnLoadArgs, OnLoadResult>[] = [];
|
||||||
|
const resolves: Handler<OnResolveArgs, OnResolveResult | undefined | null>[] = [];
|
||||||
|
const ends: ((result: BuildOutput) => void | Promise<void>)[] = [];
|
||||||
|
const builder = {
|
||||||
|
config: config ?? ({ plugins: [] } as unknown as PluginBuilder["config"]),
|
||||||
|
onLoad(hook: Hook, callback: Handler<OnLoadArgs, OnLoadResult>["callback"]) {
|
||||||
|
loads.push({ ...hook, callback });
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
onResolve(hook: Hook, callback: Handler<OnResolveArgs, OnResolveResult | undefined | null>["callback"]) {
|
||||||
|
resolves.push({ ...hook, callback });
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
onStart(callback: () => void | Promise<void>) {
|
||||||
|
void callback();
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
onEnd(callback: (result: BuildOutput) => void | Promise<void>) {
|
||||||
|
ends.push(callback);
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
onBeforeParse() {
|
||||||
|
throw new Error(`m3-bun: ${plugin.name} registers a native onBeforeParse hook, which must load with the build`);
|
||||||
|
},
|
||||||
|
module() {
|
||||||
|
throw new Error(`m3-bun: ${plugin.name} registers runtime modules, which a composed plugin cannot defer`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await plugin.setup(builder as unknown as PluginBuilder);
|
||||||
|
return {
|
||||||
|
async load(args) {
|
||||||
|
for (const hook of loads) {
|
||||||
|
if (!matches(hook, args.path, args.namespace)) continue;
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Hooks run in registration order until one answers.
|
||||||
|
const result = await hook.callback(args);
|
||||||
|
if (result !== undefined) return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
async resolve(args) {
|
||||||
|
for (const hook of resolves) {
|
||||||
|
if (!matches(hook, args.path, args.namespace)) continue;
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Hooks run in registration order until one answers.
|
||||||
|
const result = await hook.callback(args);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
async end(result) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- onEnd callbacks run in registration order.
|
||||||
|
for (const callback of ends) await callback(result);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LazyPluginSpec {
|
||||||
|
name: string;
|
||||||
|
/** Imports and creates the plugin; runs once, on the first module a hook below matches. */
|
||||||
|
load: () => Promise<BunPlugin>;
|
||||||
|
/** The filters the plugin registers: they trigger the import, then forward to its own hooks. */
|
||||||
|
onLoad?: Hook[];
|
||||||
|
onResolve?: Hook[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plugin whose package is imported on the first module that needs it. */
|
||||||
|
export function lazyPlugin(spec: LazyPluginSpec): BunPlugin {
|
||||||
|
return {
|
||||||
|
name: spec.name,
|
||||||
|
setup(build) {
|
||||||
|
let recorded: Promise<RecordedPlugin> | undefined;
|
||||||
|
const get = () => (recorded ??= spec.load().then(plugin => recordPlugin(plugin, build.config)));
|
||||||
|
for (const hook of spec.onResolve ?? []) build.onResolve(hook, async args => (await get()).resolve(args));
|
||||||
|
for (const hook of spec.onLoad ?? []) build.onLoad(hook, async args => (await get()).load(args));
|
||||||
|
build.onEnd?.(async result => {
|
||||||
|
// A failed import already failed the module that triggered it.
|
||||||
|
const plugin = await recorded?.catch(() => undefined);
|
||||||
|
await plugin?.end(result);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plugin that needs its package during setup (native hooks). */
|
||||||
|
function eagerPlugin(name: string, load: () => Promise<BunPlugin>): BunPlugin {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
async setup(build) {
|
||||||
|
await (await load()).setup(build);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
||||||
|
const OXC_SOURCE = /\.(?:[cm]?ts|tsx|jsx)$/;
|
||||||
|
|
||||||
|
/** Options of `SveltePlugin` (`@aphrody/bun-plugin-svelte`). */
|
||||||
|
export interface SvelteOptions {
|
||||||
|
forceSide?: "client" | "server";
|
||||||
|
development?: boolean;
|
||||||
|
compilerOptions?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options of `n2bPlugin` (`@aphrody/bun-plugin-n2b/plugin`); `root` defaults to the app directory. */
|
||||||
|
export interface N2BOptions {
|
||||||
|
root?: string;
|
||||||
|
mode?: "report" | "fix" | "aggressive";
|
||||||
|
onFindings?: "warn" | "error";
|
||||||
|
quiet?: boolean;
|
||||||
|
ignore?: string[];
|
||||||
|
jobs?: number;
|
||||||
|
filter?: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options of `oxcPlugin` (`@aphrody/bun-plugin-oxc`). */
|
||||||
|
export interface OxcOptions {
|
||||||
|
filter?: RegExp;
|
||||||
|
transform?: Record<string, unknown>;
|
||||||
|
sourcemap?: boolean;
|
||||||
|
minify?: boolean | Record<string, unknown>;
|
||||||
|
lint?: false | "warn" | "error" | ({ level: "warn" | "error" } & Record<string, unknown>);
|
||||||
|
dts?: { outdir: string; root?: string; stripInternal?: boolean };
|
||||||
|
/** `Bun.build` only: Oxc as a native `onBeforeParse` hook, imported during setup. */
|
||||||
|
native?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForkPluginOptions {
|
||||||
|
/** `.yaml`/`.yml` through `@aphrody/bun-plugin-yaml` (js-yaml). Default false: Bun loads YAML natively. */
|
||||||
|
yaml?: boolean;
|
||||||
|
/** `.svelte` and `.svelte.{ts,js}` through `@aphrody/bun-plugin-svelte` (needs `svelte`), or its options. Default true. */
|
||||||
|
svelte?: boolean | SvelteOptions;
|
||||||
|
/** `.mdx` compiled to JSX by `@aphrody/bun-mdx-rs` (native `onBeforeParse`, `Bun.build` only). Default false. */
|
||||||
|
mdx?: boolean;
|
||||||
|
/** Node→Bun findings or codemods at load time through `@aphrody/bun-plugin-n2b`. Default false. */
|
||||||
|
n2b?: boolean | N2BOptions;
|
||||||
|
/** Oxc transform, lint and isolated declarations through `@aphrody/bun-plugin-oxc`. Default false. */
|
||||||
|
oxc?: boolean | OxcOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectOf = <T extends object>(value: boolean | T | undefined): T | undefined =>
|
||||||
|
typeof value === "object" ? value : undefined;
|
||||||
|
|
||||||
|
/** The fork plugins selected by `options`, in composition order. */
|
||||||
|
export function forkPlugins(options: ForkPluginOptions, configDir: string = process.cwd()): BunPlugin[] {
|
||||||
|
const dirs = resolveDirs(configDir);
|
||||||
|
const plugins: BunPlugin[] = [];
|
||||||
|
if (options.yaml === true)
|
||||||
|
plugins.push(
|
||||||
|
lazyPlugin({
|
||||||
|
name: "m3-bun:yaml",
|
||||||
|
load: async () => {
|
||||||
|
const mod = await importFrom<{ default: () => BunPlugin }>("@aphrody/bun-plugin-yaml", dirs, "yaml");
|
||||||
|
return mod.default();
|
||||||
|
},
|
||||||
|
onLoad: [{ filter: /\.(yaml|yml)$/ }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (options.svelte !== false)
|
||||||
|
plugins.push(
|
||||||
|
lazyPlugin({
|
||||||
|
name: "m3-bun:svelte",
|
||||||
|
load: async () => {
|
||||||
|
const mod = await importFrom<{ SveltePlugin: (options?: SvelteOptions) => BunPlugin }>(
|
||||||
|
"@aphrody/bun-plugin-svelte",
|
||||||
|
dirs,
|
||||||
|
"svelte",
|
||||||
|
);
|
||||||
|
return mod.SveltePlugin(objectOf(options.svelte));
|
||||||
|
},
|
||||||
|
onLoad: [{ filter: /\.svelte$/ }, { filter: /\.svelte.[tj]s$/ }, { filter: /\.css$/, namespace: "bun-svelte" }],
|
||||||
|
onResolve: [{ filter: /^bun-svelte:/ }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (options.mdx === true)
|
||||||
|
plugins.push(
|
||||||
|
eagerPlugin("m3-bun:mdx", async () => {
|
||||||
|
const mod = await importFrom<{ default: () => BunPlugin }>("@aphrody/bun-mdx-rs", dirs, "mdx");
|
||||||
|
return mod.default();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (options.n2b) {
|
||||||
|
const n2b: N2BOptions = { root: configDir, ...objectOf(options.n2b) };
|
||||||
|
plugins.push(
|
||||||
|
lazyPlugin({
|
||||||
|
name: "m3-bun:n2b",
|
||||||
|
load: async () => {
|
||||||
|
const mod = await importFrom<{ n2bPlugin: (options: N2BOptions) => BunPlugin }>(
|
||||||
|
"@aphrody/bun-plugin-n2b/plugin",
|
||||||
|
dirs,
|
||||||
|
"n2b",
|
||||||
|
);
|
||||||
|
return mod.n2bPlugin(n2b);
|
||||||
|
},
|
||||||
|
onLoad: [{ filter: n2b.filter ?? SOURCE }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.oxc) {
|
||||||
|
const oxc = objectOf(options.oxc) ?? {};
|
||||||
|
const load = async () => {
|
||||||
|
const mod = await importFrom<{ oxcPlugin: (options: OxcOptions) => BunPlugin }>(
|
||||||
|
"@aphrody/bun-plugin-oxc",
|
||||||
|
dirs,
|
||||||
|
"oxc",
|
||||||
|
);
|
||||||
|
return mod.oxcPlugin(oxc);
|
||||||
|
};
|
||||||
|
plugins.push(
|
||||||
|
oxc.native
|
||||||
|
? eagerPlugin("m3-bun:oxc", load)
|
||||||
|
: lazyPlugin({ name: "m3-bun:oxc", load, onLoad: [{ filter: oxc.filter ?? OXC_SOURCE }] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Directory of the module `specifier` resolves to, for packages that resolve their own dependencies. */
|
||||||
|
export function packageDir(specifier: string, dirs: readonly string[]): string | undefined {
|
||||||
|
const path = resolveFrom(specifier, dirs);
|
||||||
|
return path ? dirname(path) : undefined;
|
||||||
|
}
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The installed buv SDK supervises native processes; Bun.spawn handles hosts without its optional library.
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export interface LaunchOptions {
|
||||||
|
args?: readonly string[];
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
cwd?: string;
|
||||||
|
/** HTTP port to wait for. */
|
||||||
|
port: number;
|
||||||
|
/** Path to wait on (default `/healthz`). */
|
||||||
|
path?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Cancel startup; the spawned process is stopped before launch rejects. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Launched {
|
||||||
|
via: "buv-runtime" | "bun-spawn";
|
||||||
|
pid: number;
|
||||||
|
/** Status of the wait: the HTTP status the path answered. */
|
||||||
|
readyStatus: number;
|
||||||
|
/** Stop the process (graceful, then forced) and release it. */
|
||||||
|
stop(): Promise<{ exitCode: number | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sdk {
|
||||||
|
Runtime: { load(): Disposable & { spawn(options: unknown): ManagedLike } };
|
||||||
|
}
|
||||||
|
interface ManagedLike extends Disposable {
|
||||||
|
status(): { running: boolean; pid: number; exitCode: number };
|
||||||
|
waitForHttp(options: {
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<number>;
|
||||||
|
stopAsync(graceMs?: number): Promise<{ exitCode: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeSdkPath(root?: string): string | undefined {
|
||||||
|
const checkout = root ?? process.env["APHRODY_BUN_CHECKOUT"];
|
||||||
|
if (checkout) {
|
||||||
|
const file = resolve(checkout, "packages/bun-runtime-sdk/src/index.ts");
|
||||||
|
return existsSync(file) ? file : undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const file = fileURLToPath(import.meta.resolve("@aphrody/buv/runtime-sdk"));
|
||||||
|
return existsSync(file) ? file : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSdk(): Promise<Sdk | undefined> {
|
||||||
|
const path = runtimeSdkPath();
|
||||||
|
if (!path) return undefined;
|
||||||
|
return (await import(path)) as Sdk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start `program`, wait until `port` answers, and hand back a handle. */
|
||||||
|
export async function launch(program: string, options: LaunchOptions): Promise<Launched> {
|
||||||
|
const path = options.path ?? "/healthz";
|
||||||
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
||||||
|
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)
|
||||||
|
throw new RangeError(`m3-bun: invalid readiness port ${options.port}`);
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
||||||
|
throw new RangeError("m3-bun: timeoutMs must be positive");
|
||||||
|
if (!path.startsWith("/") || path.startsWith("//"))
|
||||||
|
throw new Error("m3-bun: readiness path must be an absolute path on the local server");
|
||||||
|
options.signal?.throwIfAborted();
|
||||||
|
const sdk = await loadSdk();
|
||||||
|
if (sdk) {
|
||||||
|
let runtime: (Disposable & { spawn(options: unknown): ManagedLike }) | undefined;
|
||||||
|
try {
|
||||||
|
runtime = sdk.Runtime.load();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { status?: number }).status === 2 || process.env["BUV_RUNTIME_LIB"])
|
||||||
|
throw error;
|
||||||
|
runtime = undefined;
|
||||||
|
}
|
||||||
|
if (runtime) {
|
||||||
|
const loaded = runtime;
|
||||||
|
let child: ManagedLike;
|
||||||
|
try {
|
||||||
|
child = loaded.spawn({
|
||||||
|
program,
|
||||||
|
args: options.args ?? [],
|
||||||
|
env: options.env ?? {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdio: "capture",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
loaded[Symbol.dispose]();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const readyStatus = await child.waitForHttp({
|
||||||
|
port: options.port,
|
||||||
|
path,
|
||||||
|
timeoutMs,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
const pid = child.status().pid;
|
||||||
|
let stopping: Promise<{ exitCode: number | null }> | undefined;
|
||||||
|
return {
|
||||||
|
via: "buv-runtime",
|
||||||
|
pid,
|
||||||
|
readyStatus,
|
||||||
|
stop() {
|
||||||
|
return (stopping ??= (async () => {
|
||||||
|
try {
|
||||||
|
const status = await child.stopAsync(2000);
|
||||||
|
return { exitCode: status.exitCode };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
child[Symbol.dispose]();
|
||||||
|
} finally {
|
||||||
|
loaded[Symbol.dispose]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await child.stopAsync(0).catch(() => undefined);
|
||||||
|
try {
|
||||||
|
child[Symbol.dispose]();
|
||||||
|
} finally {
|
||||||
|
loaded[Symbol.dispose]();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const proc = Bun.spawn([program, ...(options.args ?? [])], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: { ...process.env, ...options.env } as Record<string, string>,
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
});
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
try {
|
||||||
|
/* eslint-disable no-await-in-loop -- Readiness probes must finish before the next probe; their deadline and cancellation are shared. */
|
||||||
|
for (;;) {
|
||||||
|
options.signal?.throwIfAborted();
|
||||||
|
if (proc.exitCode !== null || Date.now() >= deadline) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: ${program} did not answer http://127.0.0.1:${options.port}${path} within ${timeoutMs} ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
const timeout = AbortSignal.timeout(Math.min(1000, Math.max(1, deadline - Date.now())));
|
||||||
|
response = await fetch(`http://127.0.0.1:${options.port}${path}`, {
|
||||||
|
signal: options.signal ? AbortSignal.any([timeout, options.signal]) : timeout,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
options.signal?.throwIfAborted();
|
||||||
|
await Bun.sleep(Math.min(50, Math.max(0, deadline - Date.now())));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await response.body?.cancel();
|
||||||
|
let stopping: Promise<{ exitCode: number | null }> | undefined;
|
||||||
|
return {
|
||||||
|
via: "bun-spawn",
|
||||||
|
pid: proc.pid,
|
||||||
|
readyStatus: response.status,
|
||||||
|
stop() {
|
||||||
|
return (stopping ??= (async () => {
|
||||||
|
if (proc.exitCode === null) proc.kill("SIGTERM");
|
||||||
|
const exited = await Promise.race([proc.exited, Bun.sleep(2000).then(() => undefined)]);
|
||||||
|
if (exited === undefined) {
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
await proc.exited;
|
||||||
|
}
|
||||||
|
return { exitCode: proc.exitCode };
|
||||||
|
})());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop */
|
||||||
|
} catch (error) {
|
||||||
|
if (proc.exitCode === null) proc.kill("SIGKILL");
|
||||||
|
await proc.exited;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmokeCheck {
|
||||||
|
path: string;
|
||||||
|
status: number;
|
||||||
|
contentType: string;
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmokeReport {
|
||||||
|
via: Launched["via"];
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
checks: SmokeCheck[];
|
||||||
|
ok: boolean;
|
||||||
|
exitCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Launch an executable on `port` and GET each of `paths`; stops it afterwards. `expect` maps a path to the wanted content type prefix. */
|
||||||
|
export async function smoke(
|
||||||
|
program: string,
|
||||||
|
port: number,
|
||||||
|
paths: readonly string[] = ["/", "/healthz", "/api/m3/info"],
|
||||||
|
expect: Record<string, string> = {
|
||||||
|
"/": "text/html",
|
||||||
|
"/healthz": "application/json",
|
||||||
|
"/api/m3/info": "application/json",
|
||||||
|
},
|
||||||
|
): Promise<SmokeReport> {
|
||||||
|
const child = await launch(program, { port, env: { PORT: String(port) } });
|
||||||
|
const checks: SmokeCheck[] = [];
|
||||||
|
let exitCode: number | null = null;
|
||||||
|
try {
|
||||||
|
/* eslint-disable no-await-in-loop -- Smoke paths run in caller order against the one supervised process. */
|
||||||
|
for (const path of paths) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||||
|
headers: { accept: "text/html,application/json" },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
await response.arrayBuffer();
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
checks.push({
|
||||||
|
path,
|
||||||
|
status: response.status,
|
||||||
|
contentType,
|
||||||
|
ok: response.status === 200 && contentType.startsWith(expect[path] ?? ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop */
|
||||||
|
} finally {
|
||||||
|
exitCode = (await child.stop()).exitCode;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
via: child.via,
|
||||||
|
pid: child.pid,
|
||||||
|
port,
|
||||||
|
checks,
|
||||||
|
ok: checks.length > 0 && checks.every((c) => c.ok),
|
||||||
|
exitCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per check, then the verdict. */
|
||||||
|
export function formatSmoke(report: SmokeReport): string {
|
||||||
|
const lines = report.checks.map(
|
||||||
|
(c) => ` ${c.ok ? "ok " : "FAIL"} GET ${c.path} -> ${c.status} ${c.contentType}`,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
`m3-bun smoke: pid ${report.pid} on :${report.port} via ${report.via}`,
|
||||||
|
...lines,
|
||||||
|
`${report.ok ? "ok" : "failed"}; stopped with exit code ${String(report.exitCode)}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The server side of the AG-UI protocol (`@aphrody/m3-ai/ag-ui`): `POST /api/m3/ag-ui` takes a `RunAgentInput`, runs
|
||||||
|
// the agent and streams its events with the AG-UI `EventEncoder`, as server-sent events or, when the client accepts
|
||||||
|
// `application/vnd.ag-ui.event+proto`, as length-prefixed protobuf. The agent is an `AbstractAgent` (its `run`
|
||||||
|
// returns an Observable) or a function returning an (async) iterable of events. A failure ends the stream with a
|
||||||
|
// `RUN_ERROR` event. `HttpAgent` of the same package is the matching client.
|
||||||
|
import type { M3Route } from "./api.ts";
|
||||||
|
import { loadOptional } from "./server-optional.ts";
|
||||||
|
|
||||||
|
type AgUi = typeof import("@aphrody/m3-ai/ag-ui");
|
||||||
|
type RunAgentInput = import("@aphrody/m3-ai/ag-ui").RunAgentInput;
|
||||||
|
type BaseEvent = import("@aphrody/m3-ai/ag-ui").BaseEvent;
|
||||||
|
|
||||||
|
interface Observer {
|
||||||
|
next(event: BaseEvent): void;
|
||||||
|
error(error: unknown): void;
|
||||||
|
complete(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type M3AgUiAgent =
|
||||||
|
| { run(input: RunAgentInput): { subscribe(observer: Observer): { unsubscribe(): void } } }
|
||||||
|
| ((input: RunAgentInput, request: Request) => Iterable<BaseEvent> | AsyncIterable<BaseEvent>);
|
||||||
|
|
||||||
|
const isInput = (value: unknown): value is RunAgentInput => {
|
||||||
|
const input = value as Partial<RunAgentInput> | null;
|
||||||
|
return (
|
||||||
|
typeof input === "object" &&
|
||||||
|
input !== null &&
|
||||||
|
typeof input.threadId === "string" &&
|
||||||
|
typeof input.runId === "string" &&
|
||||||
|
Array.isArray(input.messages)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `POST` route serving `agent` (mount it at `/api/m3/ag-ui`). */
|
||||||
|
export function agUiRoute(agent: M3AgUiAgent): M3Route {
|
||||||
|
return {
|
||||||
|
POST: async request => {
|
||||||
|
const agUi = await loadOptional<AgUi>("@aphrody/m3-ai/ag-ui", "AG-UI endpoint");
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = await request.json();
|
||||||
|
} catch {
|
||||||
|
input = undefined;
|
||||||
|
}
|
||||||
|
if (!isInput(input))
|
||||||
|
return Response.json({ error: "body must be a RunAgentInput (threadId, runId, messages)" }, { status: 400 });
|
||||||
|
// Protobuf only when asked for by name: `*/*` (fetch's default) gets server-sent events.
|
||||||
|
const accept = request.headers.get("accept") ?? "";
|
||||||
|
const encoder = new agUi.EventEncoder({
|
||||||
|
accept: accept.includes(agUi.AGUI_MEDIA_TYPE) ? accept : "text/event-stream",
|
||||||
|
});
|
||||||
|
const proto = encoder.getContentType() === agUi.AGUI_MEDIA_TYPE;
|
||||||
|
const encode = (event: BaseEvent): string | Uint8Array =>
|
||||||
|
proto ? encoder.encodeBinary(event) : encoder.encode(event);
|
||||||
|
let subscription: { unsubscribe(): void } | undefined;
|
||||||
|
const body = new ReadableStream<string | Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const fail = (error: unknown): void => {
|
||||||
|
controller.enqueue(
|
||||||
|
encode({
|
||||||
|
type: agUi.EventType.RUN_ERROR,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
} as BaseEvent),
|
||||||
|
);
|
||||||
|
controller.close();
|
||||||
|
};
|
||||||
|
if (typeof agent === "function") {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
for await (const event of agent(input, request)) controller.enqueue(encode(event));
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subscription = agent.run(input).subscribe({
|
||||||
|
next: event => controller.enqueue(encode(event)),
|
||||||
|
error: fail,
|
||||||
|
complete: () => controller.close(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel: () => subscription?.unsubscribe(),
|
||||||
|
});
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { "Content-Type": encoder.getContentType(), "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Password accounts and sessions for an app's API, on Bun only: `Bun.password` (argon2id) for the hashes, a
|
||||||
|
// `bun:sqlite` database (see `m3Db`) for users and sessions, an HttpOnly session cookie through `request.cookies`
|
||||||
|
// (`Bun.CookieMap`), and `Bun.CSRF` tokens bound to the session for every state-changing route.
|
||||||
|
//
|
||||||
|
// const auth = m3Auth({ db: m3Db("data/app.sqlite"), secret: await ensureSecret("session-key") });
|
||||||
|
// export default defineM3Api({ ...auth.routes, "/api/notes": auth.csrf.protect({ POST: … }) });
|
||||||
|
import type { Database } from "bun:sqlite";
|
||||||
|
import { m3Csrf, readBody, type M3Csrf, type M3Request, type M3Routes } from "./api.ts";
|
||||||
|
|
||||||
|
export interface M3AuthOptions {
|
||||||
|
db: Database;
|
||||||
|
/** Signing secret of the CSRF tokens (see `ensureSecret`). */
|
||||||
|
secret: string;
|
||||||
|
/** Session cookie (default `m3-session`). */
|
||||||
|
cookie?: string;
|
||||||
|
/** Session lifetime in seconds (default 7 days). */
|
||||||
|
maxAge?: number;
|
||||||
|
/** `Secure` cookie (default true; set false for plain-HTTP development). */
|
||||||
|
secure?: boolean;
|
||||||
|
/** Route prefix (default `/api/m3/auth`). */
|
||||||
|
prefix?: string;
|
||||||
|
/** Accept new accounts through `POST <prefix>/register` (default true). */
|
||||||
|
register?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3Auth {
|
||||||
|
/** `POST register`, `POST login`, `POST logout`, `GET me` (with a fresh CSRF token) under the prefix. */
|
||||||
|
routes: M3Routes;
|
||||||
|
/** The signed-in user of a request, if any. */
|
||||||
|
user(request: M3Request): M3User | undefined;
|
||||||
|
/** CSRF tokens bound to the session: `csrf.protect(route)` guards the app's own routes. */
|
||||||
|
csrf: M3Csrf;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = (value: string): string => new Bun.CryptoHasher("sha256").update(value).digest("hex");
|
||||||
|
let dummy: Promise<string> | undefined;
|
||||||
|
const dummyHash = (): Promise<string> =>
|
||||||
|
(dummy ??= Bun.password.hash("m3-bun-dummy-password", { algorithm: "argon2id" }));
|
||||||
|
const EMAIL = /^[^\s@]+@[^\s@]+$/;
|
||||||
|
|
||||||
|
const credentials = async (request: M3Request): Promise<{ email: string; password: string } | Response> => {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await readBody(request);
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "unreadable body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { email, password } = (body ?? {}) as { email?: unknown; password?: unknown };
|
||||||
|
if (typeof email !== "string" || !EMAIL.test(email) || typeof password !== "string" || password.length < 8)
|
||||||
|
return Response.json({ error: "an email and a password of 8 characters or more are required" }, { status: 400 });
|
||||||
|
return { email, password };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function m3Auth(options: M3AuthOptions): M3Auth {
|
||||||
|
const { db, cookie = "m3-session", maxAge = 7 * 24 * 3600, secure = true, prefix = "/api/m3/auth" } = options;
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS m3_users (
|
||||||
|
id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE COLLATE NOCASE, password TEXT NOT NULL, created INTEGER NOT NULL)`);
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS m3_sessions (
|
||||||
|
token TEXT PRIMARY KEY, user INTEGER NOT NULL REFERENCES m3_users(id) ON DELETE CASCADE, expires INTEGER NOT NULL)`);
|
||||||
|
const insertUser = db.query<{ id: number }, { email: string; password: string; created: number }>(
|
||||||
|
"INSERT INTO m3_users (email, password, created) VALUES ($email, $password, $created) RETURNING id",
|
||||||
|
);
|
||||||
|
const userByEmail = db.query<M3User & { password: string }, { email: string }>(
|
||||||
|
"SELECT id, email, password FROM m3_users WHERE email = $email",
|
||||||
|
);
|
||||||
|
const insertSession = db.query<unknown, { token: string; user: number; expires: number }>(
|
||||||
|
"INSERT INTO m3_sessions (token, user, expires) VALUES ($token, $user, $expires)",
|
||||||
|
);
|
||||||
|
const userBySession = db.query<M3User, { token: string; now: number }>(
|
||||||
|
"SELECT u.id, u.email FROM m3_sessions s JOIN m3_users u ON u.id = s.user WHERE s.token = $token AND s.expires > $now",
|
||||||
|
);
|
||||||
|
const deleteSession = db.query<unknown, { token: string }>("DELETE FROM m3_sessions WHERE token = $token");
|
||||||
|
const purge = db.query<unknown, { now: number }>("DELETE FROM m3_sessions WHERE expires <= $now");
|
||||||
|
|
||||||
|
const sessionOf = (request: M3Request): string | undefined => {
|
||||||
|
const token = request.cookies.get(cookie);
|
||||||
|
return token ? sha256(token) : undefined;
|
||||||
|
};
|
||||||
|
const user = (request: M3Request): M3User | undefined => {
|
||||||
|
const session = sessionOf(request);
|
||||||
|
return session ? (userBySession.get({ token: session, now: Date.now() }) ?? undefined) : undefined;
|
||||||
|
};
|
||||||
|
const csrf = m3Csrf({ secret: options.secret, sessionOf });
|
||||||
|
|
||||||
|
const routes: M3Routes = {
|
||||||
|
[`${prefix}/login`]: {
|
||||||
|
POST: async request => {
|
||||||
|
const input = await credentials(request);
|
||||||
|
if (input instanceof Response) return input;
|
||||||
|
const found = userByEmail.get({ email: input.email });
|
||||||
|
// Verify against a dummy hash when the account is unknown, so timing does not reveal which emails exist.
|
||||||
|
const ok = await Bun.password.verify(input.password, found?.password ?? (await dummyHash()));
|
||||||
|
if (!found || !ok) return Response.json({ error: "invalid email or password" }, { status: 401 });
|
||||||
|
const now = Date.now();
|
||||||
|
purge.run({ now });
|
||||||
|
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url");
|
||||||
|
insertSession.run({ token: sha256(token), user: found.id, expires: now + maxAge * 1000 });
|
||||||
|
request.cookies.set(cookie, token, { httpOnly: true, secure, sameSite: "strict", path: "/", maxAge });
|
||||||
|
return Response.json({ user: { id: found.id, email: found.email }, csrf: csrf.token(sha256(token)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[`${prefix}/logout`]: csrf.protect({
|
||||||
|
POST: request => {
|
||||||
|
const session = sessionOf(request);
|
||||||
|
if (session) deleteSession.run({ token: session });
|
||||||
|
request.cookies.delete({ name: cookie, path: "/" });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[`${prefix}/me`]: {
|
||||||
|
GET: request => {
|
||||||
|
const current = user(request);
|
||||||
|
if (!current) return Response.json({ error: "not signed in" }, { status: 401 });
|
||||||
|
return Response.json({ user: current, csrf: csrf.token(sessionOf(request)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (options.register !== false)
|
||||||
|
routes[`${prefix}/register`] = {
|
||||||
|
POST: async request => {
|
||||||
|
const input = await credentials(request);
|
||||||
|
if (input instanceof Response) return input;
|
||||||
|
const password = await Bun.password.hash(input.password, { algorithm: "argon2id" });
|
||||||
|
try {
|
||||||
|
const { id } = insertUser.get({ email: input.email, password, created: Date.now() })!;
|
||||||
|
return Response.json({ user: { id, email: input.email } }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error).includes("UNIQUE"))
|
||||||
|
return Response.json({ error: "email already registered" }, { status: 409 });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { routes, user, csrf };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Response and value cache of an app's API on Redis or Valkey through Bun's native client (`Bun.RedisClient`). The
|
||||||
|
// client connects on first use (`REDIS_URL`, `VALKEY_URL`, else `redis://localhost:6379`); entries expire with `EX`.
|
||||||
|
//
|
||||||
|
// const cache = m3Cache({ ttl: 30 });
|
||||||
|
// export default defineM3Api({ "/api/feed": cache.route(() => Response.json(expensiveFeed())) });
|
||||||
|
import type { M3Handler } from "./api.ts";
|
||||||
|
|
||||||
|
export interface M3CacheOptions {
|
||||||
|
/** Server URL (default `REDIS_URL`, `VALKEY_URL`, else Bun's default `redis://localhost:6379`). */
|
||||||
|
url?: string;
|
||||||
|
/** An existing client (its owner closes it). */
|
||||||
|
client?: Bun.RedisClient;
|
||||||
|
/** Key prefix (default `m3:`). */
|
||||||
|
prefix?: string;
|
||||||
|
/** Default lifetime in seconds (default 60). */
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3Cache {
|
||||||
|
readonly client: Bun.RedisClient;
|
||||||
|
get<T = unknown>(key: string): Promise<T | undefined>;
|
||||||
|
set(key: string, value: unknown, ttl?: number): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
/** The cached value of `key`, else `compute()` stored for `ttl` seconds. */
|
||||||
|
wrap<T>(key: string, compute: () => T | Promise<T>, ttl?: number): Promise<T>;
|
||||||
|
/** A GET handler whose 200 responses are cached by URL (`X-Cache: HIT` or `MISS`); other methods pass through. */
|
||||||
|
route(handler: M3Handler, options?: { ttl?: number; key?: (request: Request) => string }): M3Handler;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stored {
|
||||||
|
status: number;
|
||||||
|
type: string | null;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function m3Cache(options: M3CacheOptions = {}): M3Cache {
|
||||||
|
const prefix = options.prefix ?? "m3:";
|
||||||
|
const ttl = options.ttl ?? 60;
|
||||||
|
let owned: Bun.RedisClient | undefined;
|
||||||
|
const client = (): Bun.RedisClient =>
|
||||||
|
options.client ?? (owned ??= new Bun.RedisClient(options.url ?? Bun.env.REDIS_URL ?? Bun.env.VALKEY_URL));
|
||||||
|
const seconds = (value: number | undefined): number => Math.max(1, Math.round(value ?? ttl));
|
||||||
|
|
||||||
|
const cache: M3Cache = {
|
||||||
|
get client() {
|
||||||
|
return client();
|
||||||
|
},
|
||||||
|
async get<T>(key: string) {
|
||||||
|
const raw = await client().get(prefix + key);
|
||||||
|
return raw === null ? undefined : (JSON.parse(raw) as T);
|
||||||
|
},
|
||||||
|
async set(key, value, lifetime) {
|
||||||
|
await client().set(prefix + key, JSON.stringify(value), "EX", seconds(lifetime));
|
||||||
|
},
|
||||||
|
async delete(key) {
|
||||||
|
await client().del(prefix + key);
|
||||||
|
},
|
||||||
|
async wrap<T>(key: string, compute: () => T | Promise<T>, lifetime?: number) {
|
||||||
|
const hit = await cache.get<T>(key);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const value = await compute();
|
||||||
|
await cache.set(key, value, lifetime);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
route(handler, routeOptions = {}) {
|
||||||
|
return async request => {
|
||||||
|
if (request.method !== "GET") return handler(request);
|
||||||
|
const key = `response:${routeOptions.key?.(request) ?? new URL(request.url).pathname + new URL(request.url).search}`;
|
||||||
|
const hit = await cache.get<Stored>(key);
|
||||||
|
if (hit)
|
||||||
|
return new Response(hit.body, {
|
||||||
|
status: hit.status,
|
||||||
|
headers: { ...(hit.type ? { "Content-Type": hit.type } : {}), "X-Cache": "HIT" },
|
||||||
|
});
|
||||||
|
const response = await handler(request);
|
||||||
|
if (response.status !== 200 || response.headers.has("Set-Cookie")) return response;
|
||||||
|
const body = await response.text();
|
||||||
|
await cache.set(
|
||||||
|
key,
|
||||||
|
{ status: 200, type: response.headers.get("content-type"), body } satisfies Stored,
|
||||||
|
routeOptions.ttl,
|
||||||
|
);
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set("X-Cache", "MISS");
|
||||||
|
return new Response(body, { status: 200, headers });
|
||||||
|
};
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
owned?.close();
|
||||||
|
owned = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The crawler files of a served build, from its file list, with the generators of `@aphrody/m3-next`: `/sitemap.xml`
|
||||||
|
// (the HTML pages and the Markdown documents), `/robots.txt` and `/llms.txt` (the Markdown documents, whose `.md` URL
|
||||||
|
// answers the raw source to non-browser clients). A file of the same name in the build wins.
|
||||||
|
import type { M3Routes } from "./api.ts";
|
||||||
|
import { loadOptional } from "./server-optional.ts";
|
||||||
|
|
||||||
|
type M3Next = typeof import("@aphrody/m3-next");
|
||||||
|
|
||||||
|
export interface M3CrawlOptions {
|
||||||
|
/** Public origin, `https://example.com`: the files hold absolute URLs. */
|
||||||
|
origin: string;
|
||||||
|
/** Site name, the H1 of `/llms.txt` (default: the app name). */
|
||||||
|
name?: string;
|
||||||
|
/** One-paragraph summary of `/llms.txt`. */
|
||||||
|
summary?: string;
|
||||||
|
/** Path prefixes kept out of the sitemap and disallowed in `/robots.txt`. */
|
||||||
|
disallow?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public URL path of a served file: `/a/index.html` -> `/a/`, `/b.html` -> `/b`, `/c.md` -> `/c.md`; else undefined. */
|
||||||
|
export function pagePath(file: string): string | undefined {
|
||||||
|
if (file.endsWith("/index.html")) return file.slice(0, -"index.html".length);
|
||||||
|
if (file.endsWith(".html")) return file.slice(0, -".html".length);
|
||||||
|
if (file.endsWith(".md")) return file;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = () => loadOptional<M3Next>("@aphrody/m3-next", "crawl");
|
||||||
|
const text = (body: string, type: string) =>
|
||||||
|
new Response(body, { headers: { "Content-Type": type, "Cache-Control": "public, max-age=3600" } });
|
||||||
|
|
||||||
|
/** `/sitemap.xml`, `/robots.txt` and `/llms.txt` routes for the files `paths` (URL paths of the build). */
|
||||||
|
export function crawlRoutes(paths: readonly string[], options: M3CrawlOptions & { name: string }): M3Routes {
|
||||||
|
const disallow = options.disallow ?? [];
|
||||||
|
const pages = paths
|
||||||
|
.map(pagePath)
|
||||||
|
.filter((path): path is string => path !== undefined)
|
||||||
|
.filter(path => !disallow.some(prefix => path.startsWith(prefix)));
|
||||||
|
const routes: M3Routes = {};
|
||||||
|
if (!paths.includes("/sitemap.xml"))
|
||||||
|
routes["/sitemap.xml"] = async () =>
|
||||||
|
text((await next()).m3SitemapXml(pages, options.origin), "application/xml; charset=utf-8");
|
||||||
|
if (!paths.includes("/robots.txt"))
|
||||||
|
routes["/robots.txt"] = async () =>
|
||||||
|
text(
|
||||||
|
(await next()).m3RobotsTxt({
|
||||||
|
origin: options.origin,
|
||||||
|
rules: [{ userAgent: "*", allow: ["/"], ...(disallow.length ? { disallow } : {}) }],
|
||||||
|
}),
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
);
|
||||||
|
if (!paths.includes("/llms.txt"))
|
||||||
|
routes["/llms.txt"] = async () => {
|
||||||
|
const docs = pages.filter(path => path.endsWith(".md"));
|
||||||
|
const html = pages.filter(path => !path.endsWith(".md"));
|
||||||
|
const sections = [
|
||||||
|
...(docs.length ? [{ title: "Docs", links: docs.map(path => ({ title: path, path })) }] : []),
|
||||||
|
...(html.length ? [{ title: "Pages", links: html.map(path => ({ title: path, path })), optional: true }] : []),
|
||||||
|
];
|
||||||
|
return text(
|
||||||
|
(await next()).m3LlmsTxt({
|
||||||
|
origin: options.origin,
|
||||||
|
name: options.name,
|
||||||
|
...(options.summary ? { summary: options.summary } : {}),
|
||||||
|
sections,
|
||||||
|
}),
|
||||||
|
"text/markdown; charset=utf-8",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The database of an app's API: one `bun:sqlite` file next to the server, opened ready for a web server (WAL,
|
||||||
|
// foreign keys, a busy timeout, strict named parameters) and migrated forward with `PRAGMA user_version`.
|
||||||
|
//
|
||||||
|
// const db = m3Db({ path: "data/app.sqlite", migrations: ["CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)"] });
|
||||||
|
// export default defineM3Api({ "/api/notes": () => Response.json(db.query("SELECT * FROM notes").all()) });
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
export interface M3DbOptions {
|
||||||
|
/** Database file (default `:memory:`); its directory is created. */
|
||||||
|
path?: string;
|
||||||
|
/** Schema steps, in order, each applied once in its own transaction: step `i` brings `user_version` to `i + 1`. */
|
||||||
|
migrations?: readonly (string | ((db: Database) => void))[];
|
||||||
|
readonly?: boolean;
|
||||||
|
/** Milliseconds a writer waits for a lock (default 5000). */
|
||||||
|
busyTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open and migrate the API database. */
|
||||||
|
export function m3Db(options: M3DbOptions | string = {}): Database {
|
||||||
|
const {
|
||||||
|
path = ":memory:",
|
||||||
|
migrations = [],
|
||||||
|
readonly = false,
|
||||||
|
busyTimeout = 5000,
|
||||||
|
} = typeof options === "string" ? { path: options } : options;
|
||||||
|
const memory = path === ":memory:" || path === "";
|
||||||
|
if (!memory && !readonly) mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const db = new Database(path, readonly ? { readonly: true, strict: true } : { create: true, strict: true });
|
||||||
|
db.run(`PRAGMA busy_timeout = ${Math.max(0, Math.trunc(busyTimeout))}`);
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
if (readonly) return db;
|
||||||
|
if (!memory) db.run("PRAGMA journal_mode = WAL");
|
||||||
|
const current = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
|
||||||
|
if (current > migrations.length)
|
||||||
|
throw new Error(
|
||||||
|
`m3Db: ${path} is at schema version ${current}, newer than the ${migrations.length} migrations of this build`,
|
||||||
|
);
|
||||||
|
for (let version = current; version < migrations.length; version++) {
|
||||||
|
const step = migrations[version]!;
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
if (typeof step === "string") db.run(step);
|
||||||
|
else step(db);
|
||||||
|
db.run(`PRAGMA user_version = ${version + 1}`);
|
||||||
|
})();
|
||||||
|
} catch (error) {
|
||||||
|
db.close();
|
||||||
|
throw new Error(`m3Db: migration ${version + 1} of ${path} failed`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// What the file server writes into the HTML it serves, with `HTMLRewriter` (streamed, no re-parse): a per-request CSP
|
||||||
|
// nonce on every `<script>`, and the M3 theme of the request. The theme follows `@aphrody/m3-next`: the `m3-theme`
|
||||||
|
// cookie (`light`, `dark` or `system`), the `.dark`/`.light` class and `color-scheme` on `<html>`, the pre-paint
|
||||||
|
// script for `system`, and `theme-color` metas from the scheme surface of the seed.
|
||||||
|
import { loadOptional } from "./server-optional.ts";
|
||||||
|
|
||||||
|
type M3Next = typeof import("@aphrody/m3-next");
|
||||||
|
export type M3ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
export interface M3ThemeOptions {
|
||||||
|
/** Cookie holding the preference (default `m3-theme`, the one `@aphrody/m3-next` writes). */
|
||||||
|
cookie?: string;
|
||||||
|
/** Mode when the cookie is absent or invalid (default `system`). */
|
||||||
|
defaultMode?: M3ThemeMode;
|
||||||
|
/** Source colour of the `theme-color` metas (default the M3 baseline); `false` omits them. */
|
||||||
|
seed?: string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3ResolvedTheme {
|
||||||
|
mode: M3ThemeMode;
|
||||||
|
/** Cookie to set when the request had no valid one. */
|
||||||
|
setCookie?: string;
|
||||||
|
colorScheme: string;
|
||||||
|
/** Pre-paint script: resolves `system` and keeps classes in sync with the cookie. */
|
||||||
|
script: string;
|
||||||
|
themeColors?: { light: string; dark: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh CSP nonce (128 random bits, base64). */
|
||||||
|
export function createNonce(): string {
|
||||||
|
return Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `csp` with `'nonce-…'` allowed in `script-src` (added from `default-src` when the policy has no `script-src`). */
|
||||||
|
export function cspWithNonce(csp: string, nonce: string): string {
|
||||||
|
const source = `'nonce-${nonce}'`;
|
||||||
|
const directives = csp
|
||||||
|
.split(";")
|
||||||
|
.map(directive => directive.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const index = directives.findIndex(directive => /^script-src(\s|$)/i.test(directive));
|
||||||
|
if (index >= 0) directives[index] = `${directives[index]} ${source}`;
|
||||||
|
else {
|
||||||
|
const fallback = directives.find(directive => /^default-src(\s|$)/i.test(directive));
|
||||||
|
directives.push(`script-src${fallback ? fallback.slice("default-src".length) : " 'self'"} ${source}`);
|
||||||
|
}
|
||||||
|
return directives.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeColors = new Map<string, { light: string; dark: string }>();
|
||||||
|
|
||||||
|
/** The theme of a request, from its cookie (`Bun.CookieMap`), with the helpers of `@aphrody/m3-next`. */
|
||||||
|
export async function resolveTheme(cookies: Bun.CookieMap, options: M3ThemeOptions = {}): Promise<M3ResolvedTheme> {
|
||||||
|
const next = await loadOptional<M3Next>("@aphrody/m3-next", "theme");
|
||||||
|
const name = next.validateThemeCookieName(options.cookie ?? next.M3_THEME_COOKIE);
|
||||||
|
const current = cookies.get(name);
|
||||||
|
const mode = next.parseThemeMode(current, options.defaultMode ?? "system");
|
||||||
|
const seed = options.seed === false ? undefined : (options.seed ?? next.M3_BASELINE_SEED);
|
||||||
|
let colors = seed === undefined ? undefined : themeColors.get(seed);
|
||||||
|
if (seed !== undefined && !colors) {
|
||||||
|
colors = next.m3ThemeColors({ seed });
|
||||||
|
themeColors.set(seed, colors);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
...(current === mode ? {} : { setCookie: next.themeCookie(mode, name) }),
|
||||||
|
colorScheme: next.colorSchemeFor(mode),
|
||||||
|
script: next.colorSchemeScript(name),
|
||||||
|
...(colors ? { themeColors: colors } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlInjection {
|
||||||
|
nonce?: string;
|
||||||
|
theme?: M3ResolvedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeAttribute = (value: string): string =>
|
||||||
|
value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
||||||
|
|
||||||
|
/** `response` (HTML) rewritten with the nonce and the theme; headers are kept. */
|
||||||
|
export function injectHtml(response: Response, { nonce, theme }: HtmlInjection): Response {
|
||||||
|
const rewriter = new HTMLRewriter();
|
||||||
|
if (nonce)
|
||||||
|
rewriter.on("script", {
|
||||||
|
element(element) {
|
||||||
|
if (!element.hasAttribute("nonce")) element.setAttribute("nonce", nonce);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (theme) {
|
||||||
|
rewriter.on("html", {
|
||||||
|
element(element) {
|
||||||
|
element.setAttribute("data-m3-theme", theme.mode);
|
||||||
|
if (theme.mode !== "system") {
|
||||||
|
const classes = (element.getAttribute("class") ?? "")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(name => name && name !== "dark" && name !== "light");
|
||||||
|
element.setAttribute("class", [...classes, theme.mode].join(" "));
|
||||||
|
}
|
||||||
|
const style = (element.getAttribute("style") ?? "").replace(/color-scheme\s*:[^;]*;?/g, "").trim();
|
||||||
|
element.setAttribute(
|
||||||
|
"style",
|
||||||
|
`${style}${style && !style.endsWith(";") ? ";" : ""}color-scheme:${theme.colorScheme}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
rewriter.on("head", {
|
||||||
|
element(element) {
|
||||||
|
const attribute = nonce ? ` nonce="${escapeAttribute(nonce)}"` : "";
|
||||||
|
element.prepend(`<script${attribute}>${theme.script}</script>`, { html: true });
|
||||||
|
const colors = theme.themeColors;
|
||||||
|
if (!colors) return;
|
||||||
|
const metas =
|
||||||
|
theme.mode === "system"
|
||||||
|
? [
|
||||||
|
`<meta name="theme-color" media="(prefers-color-scheme: light)" content="${colors.light}">`,
|
||||||
|
`<meta name="theme-color" media="(prefers-color-scheme: dark)" content="${colors.dark}">`,
|
||||||
|
]
|
||||||
|
: [`<meta name="theme-color" content="${colors[theme.mode]}">`];
|
||||||
|
element.append(metas.join(""), { html: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rewriter.transform(response);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The route assembly of `@aphrody/m3/server` (proxy prefixes, `.html` pages, React pages of a `pagesDir`) reused for
|
||||||
|
// `startM3Server({ routes })`, so an m3-bun server serves the same routes as `serveM3` next to its build and API.
|
||||||
|
import type { M3SocketData } from "./server-realtime.ts";
|
||||||
|
import { loadOptional } from "./server-optional.ts";
|
||||||
|
|
||||||
|
type M3Server = typeof import("@aphrody/m3/server");
|
||||||
|
|
||||||
|
export interface M3ServerRoutesOptions {
|
||||||
|
/** Proxy prefixes: `"/rpc" -> "http://localhost:8080"`. */
|
||||||
|
proxy?: Record<string, string>;
|
||||||
|
/** Directory of React `.tsx`/`.jsx` pages (`index.tsx` is `/`, `[id].tsx` is `/:id`). */
|
||||||
|
pagesDir?: string;
|
||||||
|
/** Extra routes; a string value is the path of an `.html` page (bundled by `Bun.serve`). */
|
||||||
|
routes?: Record<string, Bun.Serve.Routes<undefined, string>[string] | string>;
|
||||||
|
/** Base directory of relative paths (default: working directory). */
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Bun.serve` routes of `@aphrody/m3/server`'s `buildRoutes`, for `startM3Server({ routes })`. */
|
||||||
|
export async function m3ServerRoutes(options: M3ServerRoutesOptions): Promise<Bun.Serve.Routes<M3SocketData, string>> {
|
||||||
|
const server = await loadOptional<M3Server>("@aphrody/m3/server", "m3 server routes");
|
||||||
|
const { proxy, pagesDir, routes, cwd } = options;
|
||||||
|
return (await server.buildRoutes({ proxy, pagesDir, routes, cwd })) as Bun.Serve.Routes<M3SocketData, string>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `.md` files served as HTML pages: `Bun.markdown.html` (GFM, heading ids, the GFM tag filter) in a minimal document,
|
||||||
|
// with an optional YAML front matter (`Bun.YAML.parse`) for the title, description and language.
|
||||||
|
|
||||||
|
export interface M3MarkdownOptions extends Bun.markdown.Options {
|
||||||
|
/** Stylesheet linked from every page (`/assets/markdown.css`), if any. */
|
||||||
|
stylesheet?: string;
|
||||||
|
/** `lang` of the page when the front matter has none (default `en`). */
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3MarkdownPage {
|
||||||
|
html: string;
|
||||||
|
/** The front matter, `{}` without one. */
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||||
|
|
||||||
|
const escapeHtml = (value: string): string =>
|
||||||
|
value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
|
||||||
|
/** The page of a Markdown source. */
|
||||||
|
export function renderMarkdown(source: string, options: M3MarkdownOptions = {}): M3MarkdownPage {
|
||||||
|
const { stylesheet, lang = "en", ...markdown } = options;
|
||||||
|
const match = FRONT_MATTER.exec(source);
|
||||||
|
const parsed = match ? Bun.YAML.parse(match[1]!) : undefined;
|
||||||
|
const data =
|
||||||
|
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||||
|
const body = match ? source.slice(match[0].length) : source;
|
||||||
|
const content = Bun.markdown.html(body, {
|
||||||
|
tagFilter: true,
|
||||||
|
autolinks: true,
|
||||||
|
...markdown,
|
||||||
|
headings: markdown.headings ?? { ids: true },
|
||||||
|
});
|
||||||
|
const heading = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(content)?.[1]?.replace(/<[^>]+>/g, "");
|
||||||
|
const title = typeof data.title === "string" ? escapeHtml(data.title) : (heading ?? "Document");
|
||||||
|
const description =
|
||||||
|
typeof data.description === "string" ? `<meta name="description" content="${escapeHtml(data.description)}">` : "";
|
||||||
|
const link = stylesheet ? `<link rel="stylesheet" href="${escapeHtml(stylesheet)}">` : "";
|
||||||
|
const language = escapeHtml(typeof data.lang === "string" ? data.lang : lang);
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
html: `<!doctype html><html lang="${language}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${description}${link}</head><body><main class="m3-markdown">${content}</main></body></html>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The M3 design tools of `@aphrody/m3-mcp` over HTTP, in the shapes of its tool-host protocol (the one the single
|
||||||
|
// Aphrody MCP server speaks to it): `GET /api/m3/mcp` lists the tools (`tool-host list`), `POST /api/m3/mcp/<tool>` with
|
||||||
|
// the JSON arguments runs one (`tool-host call`) and answers `{content, isError?}`. The tools run in this process
|
||||||
|
// (loaded on the first request), or, with `isolate`, in a `tool-host` child per call so their dependencies never load
|
||||||
|
// into the server.
|
||||||
|
import type { M3Routes } from "./api.ts";
|
||||||
|
import { loadOptional } from "./server-optional.ts";
|
||||||
|
|
||||||
|
type M3Mcp = typeof import("@aphrody/m3-mcp");
|
||||||
|
type ToolResult = Awaited<ReturnType<M3Mcp["callM3Tool"]>>;
|
||||||
|
|
||||||
|
export interface M3McpOptions {
|
||||||
|
/** Route prefix (default `/api/m3/mcp`). */
|
||||||
|
path?: string;
|
||||||
|
/** Run each call in a `@aphrody/m3-mcp/tool-host` child process. */
|
||||||
|
isolate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE = "MCP tools endpoint";
|
||||||
|
const mcp = () => loadOptional<M3Mcp>("@aphrody/m3-mcp", FEATURE);
|
||||||
|
|
||||||
|
async function toolHost(args: string[]): Promise<string> {
|
||||||
|
let script: string;
|
||||||
|
try {
|
||||||
|
script = Bun.resolveSync("@aphrody/m3-mcp/tool-host", import.meta.dir);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun ${FEATURE}: cannot resolve @aphrody/m3-mcp/tool-host; add @aphrody/m3-mcp to the app's dependencies`,
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const child = Bun.spawn([process.execPath, script, ...args], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
child.exited,
|
||||||
|
]);
|
||||||
|
if (code !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun ${FEATURE}: tool-host ${args[0]} exited with ${code}: ${stderr.trim()}`,
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The routes of the endpoint. */
|
||||||
|
export function m3McpRoutes(options: M3McpOptions = {}): M3Routes {
|
||||||
|
const path = (options.path ?? "/api/m3/mcp").replace(/\/+$/, "");
|
||||||
|
const list = async (): Promise<unknown> =>
|
||||||
|
options.isolate ? JSON.parse(await toolHost(["list"])) : (await mcp()).listM3Tools();
|
||||||
|
const call = async (name: string, args: unknown, signal: AbortSignal): Promise<ToolResult> =>
|
||||||
|
options.isolate
|
||||||
|
? (JSON.parse(await toolHost(["call", name, JSON.stringify(args)])) as ToolResult)
|
||||||
|
: (await mcp()).callM3Tool(name, args, signal);
|
||||||
|
return {
|
||||||
|
[path]: { GET: async () => Response.json({ tools: await list() }) },
|
||||||
|
[`${path}/:tool`]: {
|
||||||
|
POST: async (request) => {
|
||||||
|
let args: unknown = {};
|
||||||
|
const text = await request.text();
|
||||||
|
if (text.trim() !== "") {
|
||||||
|
try {
|
||||||
|
args = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ content: [{ type: "text", text: "arguments are not JSON" }], isError: true },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await call(request.params.tool!, args, request.signal);
|
||||||
|
return Response.json(result, { status: result.isError ? 422 : 200 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// A Next.js app behind an m3-bun server: `@aphrody/next-bun/run` starts the app's own `next dev|start` on Bun (no
|
||||||
|
// Node.js, Turbopack workers included) on a free port, and `@aphrody/m3/server`'s `createProxy` forwards the requests
|
||||||
|
// the m3-bun routes do not answer (pass `next.fetch` as the server's fallback, or mount it under a prefix).
|
||||||
|
import type { Subprocess } from "bun";
|
||||||
|
import { loadOptional } from "./server-optional.ts";
|
||||||
|
|
||||||
|
type NextRun = typeof import("@aphrody/next-bun/run");
|
||||||
|
type M3Server = typeof import("@aphrody/m3/server");
|
||||||
|
|
||||||
|
export interface M3NextOptions {
|
||||||
|
/** Directory of the Next app (it must depend on `next`). */
|
||||||
|
dir: string;
|
||||||
|
/** `dev` (default) or `start` (after `next build`). */
|
||||||
|
mode?: "dev" | "start";
|
||||||
|
/** Interface Next binds (default 127.0.0.1). */
|
||||||
|
hostname?: string;
|
||||||
|
/** Extra arguments of the Next command. */
|
||||||
|
args?: string[];
|
||||||
|
/** Milliseconds to wait for Next to announce its URL (default 120000). */
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3NextApp {
|
||||||
|
/** Origin of the Next server, `http://127.0.0.1:<port>`. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Forwards a request to Next (keeps path, query, method, headers and body). */
|
||||||
|
fetch(request: Request): Promise<Response>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL = /-\s+Local:\s+(https?:\/\/\S+)/;
|
||||||
|
|
||||||
|
async function announcedUrl(
|
||||||
|
child: Subprocess<"ignore", "pipe", "pipe">,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let seen = "";
|
||||||
|
const reader = child.stdout.getReader();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
void reader.cancel();
|
||||||
|
}, timeout);
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Output is scanned as it arrives, chunk after chunk.
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
seen += decoder.decode(value, { stream: true });
|
||||||
|
const match = LOCAL.exec(seen);
|
||||||
|
if (match) {
|
||||||
|
reader.releaseLock();
|
||||||
|
void child.stdout.pipeTo(new WritableStream()).catch(() => {});
|
||||||
|
return match[1]!.replace("://localhost", "://127.0.0.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const stderr = await new Response(child.stderr).text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun next: next exited or timed out before serving: ${(stderr || seen).trim().slice(-2000)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the Next app of `options.dir` and return its URL and proxy. */
|
||||||
|
export async function startNextApp(options: M3NextOptions): Promise<M3NextApp> {
|
||||||
|
const [run, server] = await Promise.all([
|
||||||
|
loadOptional<NextRun>("@aphrody/next-bun/run", "Next.js app"),
|
||||||
|
loadOptional<M3Server>("@aphrody/m3/server", "Next.js app"),
|
||||||
|
]);
|
||||||
|
const args = [
|
||||||
|
options.mode ?? "dev",
|
||||||
|
"-p",
|
||||||
|
"0",
|
||||||
|
"-H",
|
||||||
|
options.hostname ?? "127.0.0.1",
|
||||||
|
...(options.args ?? []),
|
||||||
|
];
|
||||||
|
let command: ReturnType<NextRun["nextCommand"]>;
|
||||||
|
try {
|
||||||
|
command = run.nextCommand(args, options.dir);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`m3-bun next: ${options.dir} has no next dependency`, { cause: error });
|
||||||
|
}
|
||||||
|
const child = Bun.spawn(command.cmd, {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: options.dir,
|
||||||
|
env: command.env,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
url = await announcedUrl(child, options.timeout ?? 120_000);
|
||||||
|
} catch (error) {
|
||||||
|
child.kill();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
void child.stderr.pipeTo(new WritableStream()).catch(() => {});
|
||||||
|
const proxy = server.createProxy(url, "next");
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
fetch: proxy,
|
||||||
|
async stop() {
|
||||||
|
child.kill();
|
||||||
|
await child.exited;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Optional integrations of the m3-bun server are imported on first use, never at start-up. A missing package fails
|
||||||
|
// with the feature that needed it and the package to install.
|
||||||
|
|
||||||
|
const loaded = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
/** `import(specifier)` once; a failure names the feature and the package. */
|
||||||
|
export function loadOptional<T>(specifier: string, feature: string): Promise<T> {
|
||||||
|
let pending = loaded.get(specifier);
|
||||||
|
if (!pending) {
|
||||||
|
pending = import(specifier).catch((error: unknown) => {
|
||||||
|
loaded.delete(specifier);
|
||||||
|
const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
||||||
|
throw new Error(`m3-bun ${feature}: cannot load ${specifier}; add ${name} to the app's dependencies`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
loaded.set(specifier, pending);
|
||||||
|
}
|
||||||
|
return pending as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory of an installed package (through its exported `package.json`), or an explicit error. */
|
||||||
|
export function packageDir(name: string, feature: string, from: string = import.meta.dir): string {
|
||||||
|
try {
|
||||||
|
return Bun.resolveSync(`${name}/package.json`, from).replace(/[\\/]package\.json$/, "");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`m3-bun ${feature}: ${name} is not installed; add it to the app's dependencies`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Realtime topics of the m3-bun server on Bun's native WebSockets: `GET /api/m3/ws?topic=a&topic=b` upgrades and
|
||||||
|
// subscribes the socket to its topics; the server publishes with `publish(server, topic, data)` (`server.publish`,
|
||||||
|
// fan-out in native code). With `relay`, a client message `{"topic": "a", "data": …}` reaches the other subscribers.
|
||||||
|
// Every frame is the JSON envelope `{"topic", "data"}`.
|
||||||
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
||||||
|
|
||||||
|
export interface M3SocketData {
|
||||||
|
topics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3RealtimeOptions {
|
||||||
|
/** Upgrade path (default `/api/m3/ws`). */
|
||||||
|
path?: string;
|
||||||
|
/** Topics of a connection (default: its `topic` query parameters); `undefined` refuses it with 403. */
|
||||||
|
topics?: (request: Request) => string[] | undefined | Promise<string[] | undefined>;
|
||||||
|
/** Let clients publish to the topics they subscribed to (default false: the server alone publishes). */
|
||||||
|
relay?: boolean;
|
||||||
|
/** Every client message, after the relay. */
|
||||||
|
message?: (socket: ServerWebSocket<M3SocketData>, message: string | Buffer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOPIC = /^[\w.:/-]{1,128}$/;
|
||||||
|
|
||||||
|
/** Publish `data` to the subscribers of `topic`; resolves the bytes sent (0 when nobody listens). */
|
||||||
|
export function publish(server: Pick<Server<M3SocketData>, "publish">, topic: string, data: unknown): number {
|
||||||
|
return server.publish(topic, JSON.stringify({ topic, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The upgrade route and the `websocket` handler `Bun.serve` needs. */
|
||||||
|
export function realtimeHandlers(options: M3RealtimeOptions = {}): {
|
||||||
|
path: string;
|
||||||
|
upgrade(request: Request, server: Server<M3SocketData>): Promise<Response | undefined>;
|
||||||
|
websocket: WebSocketHandler<M3SocketData>;
|
||||||
|
} {
|
||||||
|
const path = options.path ?? "/api/m3/ws";
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
async upgrade(request, server) {
|
||||||
|
const topics = options.topics ? await options.topics(request) : new URL(request.url).searchParams.getAll("topic");
|
||||||
|
if (!topics) return new Response("Forbidden", { status: 403 });
|
||||||
|
if (!topics.every(topic => TOPIC.test(topic))) return new Response("Bad Request: invalid topic", { status: 400 });
|
||||||
|
if (server.upgrade(request, { data: { topics } })) return undefined;
|
||||||
|
return new Response("Upgrade Required", { status: 426, headers: { Upgrade: "websocket" } });
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
open(socket) {
|
||||||
|
for (const topic of socket.data.topics) socket.subscribe(topic);
|
||||||
|
},
|
||||||
|
message(socket, message) {
|
||||||
|
if (options.relay && typeof message === "string") {
|
||||||
|
let envelope: { topic?: unknown; data?: unknown } | undefined;
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(message) as { topic?: unknown; data?: unknown };
|
||||||
|
} catch {
|
||||||
|
envelope = undefined;
|
||||||
|
}
|
||||||
|
const topic = envelope?.topic;
|
||||||
|
if (typeof topic === "string" && socket.isSubscribed(topic))
|
||||||
|
socket.publish(topic, JSON.stringify({ topic, data: envelope?.data }));
|
||||||
|
}
|
||||||
|
options.message?.(socket, message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Secrets of an app's server (session and CSRF signing keys, API tokens): the environment first (containers, CI),
|
||||||
|
// then the OS credential store through `Bun.secrets` (Keychain, libsecret, Windows Credential Manager).
|
||||||
|
|
||||||
|
export interface M3SecretOptions {
|
||||||
|
/** Credential store service (default `m3-bun`). */
|
||||||
|
service?: string;
|
||||||
|
/** Environment variable checked first (default: the name in upper snake case, `session-key` -> `SESSION_KEY`). */
|
||||||
|
env?: string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envName = (name: string): string => name.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
|
||||||
|
|
||||||
|
/** The secret `name`: its environment variable, else the credential store entry; undefined when neither has it. */
|
||||||
|
export async function m3Secret(name: string, options: M3SecretOptions = {}): Promise<string | undefined> {
|
||||||
|
const variable = options.env === false ? undefined : (options.env ?? envName(name));
|
||||||
|
const fromEnv = variable ? Bun.env[variable] : undefined;
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
return (await Bun.secrets.get({ service: options.service ?? "m3-bun", name })) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The secret `name`, created on first use: 32 random bytes (base64url) stored in the credential store, so the key
|
||||||
|
* survives restarts without ever being written to a file.
|
||||||
|
*/
|
||||||
|
export async function ensureSecret(name: string, options: M3SecretOptions & { bytes?: number } = {}): Promise<string> {
|
||||||
|
const existing = await m3Secret(name, options);
|
||||||
|
if (existing) return existing;
|
||||||
|
const value = Buffer.from(crypto.getRandomValues(new Uint8Array(options.bytes ?? 32))).toString("base64url");
|
||||||
|
try {
|
||||||
|
await Bun.secrets.set({ service: options.service ?? "m3-bun", name, value });
|
||||||
|
} catch (error) {
|
||||||
|
const variable = options.env === false ? "" : ` or set ${options.env ?? envName(name)}`;
|
||||||
|
throw new Error(`m3-bun: cannot store the secret ${name} in the OS credential store${variable}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The one Bun file server of an M3 app: the UI files and the API are served by the same process, from the
|
||||||
|
// same `Bun.serve`. `createM3App` is the pure part (a `fetch` handler, testable without a socket);
|
||||||
|
// `startM3Server` binds it. The UI comes from a build directory, from files embedded in a compiled
|
||||||
|
// executable, or (development) from an HTML import that Bun bundles with HMR.
|
||||||
|
//
|
||||||
|
// const server = startM3Server({ files: dirSource("dist"), api, port: 3000 });
|
||||||
|
import type { HTMLBundle, Server } from "bun";
|
||||||
|
import { Glob } from "bun";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
import { extname, join } from "node:path";
|
||||||
|
import { brotliCompressSync, constants as zlib } from "node:zlib";
|
||||||
|
import { normalizeApi, requestCookies, routesToFetch, type M3ApiModule, type M3Routes } from "./api.ts";
|
||||||
|
import { crawlRoutes, type M3CrawlOptions } from "./server-crawl.ts";
|
||||||
|
import {
|
||||||
|
createNonce,
|
||||||
|
cspWithNonce,
|
||||||
|
injectHtml,
|
||||||
|
resolveTheme,
|
||||||
|
type M3ResolvedTheme,
|
||||||
|
type M3ThemeOptions,
|
||||||
|
} from "./server-html.ts";
|
||||||
|
import { renderMarkdown, type M3MarkdownOptions } from "./server-markdown.ts";
|
||||||
|
import { realtimeHandlers, type M3RealtimeOptions, type M3SocketData } from "./server-realtime.ts";
|
||||||
|
|
||||||
|
/** A set of files addressed by URL path (`/index.html`). */
|
||||||
|
export interface FileSource {
|
||||||
|
has(path: string): boolean;
|
||||||
|
file(path: string): Bun.BunFile;
|
||||||
|
/** Weak validator for `path`. */
|
||||||
|
etag(path: string): string;
|
||||||
|
paths(): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files of a build directory, listed once at start. Only listed files are ever served. */
|
||||||
|
export function dirSource(dir: string): FileSource {
|
||||||
|
const entries = new Map<string, { file: Bun.BunFile; etag: string }>();
|
||||||
|
for (const rel of new Glob("**/*").scanSync({ cwd: dir, onlyFiles: true })) {
|
||||||
|
if (rel.endsWith(".map")) continue;
|
||||||
|
const abs = join(dir, rel);
|
||||||
|
const stat = statSync(abs);
|
||||||
|
entries.set(`/${rel.replaceAll("\\", "/")}`, {
|
||||||
|
file: Bun.file(abs),
|
||||||
|
etag: `W/"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sourceOf(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files embedded in a compiled executable: URL path -> the path `import x from "..." with { type: "file" }` returned. */
|
||||||
|
export function embeddedSource(files: Readonly<Record<string, string>>, buildId = "0"): FileSource {
|
||||||
|
const entries = new Map<string, { file: Bun.BunFile; etag: string }>();
|
||||||
|
for (const [path, embedded] of Object.entries(files)) {
|
||||||
|
const file = Bun.file(embedded);
|
||||||
|
entries.set(path, { file, etag: `W/"${file.size.toString(16)}-${buildId}"` });
|
||||||
|
}
|
||||||
|
return sourceOf(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceOf(entries: Map<string, { file: Bun.BunFile; etag: string }>): FileSource {
|
||||||
|
return {
|
||||||
|
has: path => entries.has(path),
|
||||||
|
file: path => entries.get(path)!.file,
|
||||||
|
etag: path => entries.get(path)!.etag,
|
||||||
|
paths: () => [...entries.keys()].toSorted(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3AppOptions {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
/** Identifies this build in ETags of embedded files. */
|
||||||
|
buildId?: string;
|
||||||
|
/** The UI files. Without them only the API and the health routes answer. */
|
||||||
|
files?: FileSource;
|
||||||
|
/** The API: Bun routes, or `{ routes, fetch }`. */
|
||||||
|
api?: M3ApiModule;
|
||||||
|
/** Content-Security-Policy value, or `false` to omit it. The default allows only the app's own origin. */
|
||||||
|
csp?: string | false;
|
||||||
|
/** Answer unknown extension-less GET requests that accept HTML with `index.html` (default true). */
|
||||||
|
spa?: boolean;
|
||||||
|
/** Health route (default `/healthz`), or `false`. */
|
||||||
|
health?: string | false;
|
||||||
|
/** Extra fields of the `/api/m3/info` document. */
|
||||||
|
info?: Record<string, unknown>;
|
||||||
|
/** A fresh CSP nonce per HTML response, set on its `<script>` elements and allowed in `script-src`. */
|
||||||
|
nonce?: boolean;
|
||||||
|
/** The request's M3 theme (cookie, class, `color-scheme`, `theme-color`) written into served HTML (`@aphrody/m3-next`). */
|
||||||
|
theme?: boolean | M3ThemeOptions;
|
||||||
|
/** Serve `.md` files as HTML pages to browsers (`Bun.markdown`), raw to other clients; `/a` finds `/a.md`. */
|
||||||
|
markdown?: boolean | M3MarkdownOptions;
|
||||||
|
/** `/sitemap.xml`, `/robots.txt` and `/llms.txt` from the served files (`@aphrody/m3-next`). */
|
||||||
|
crawl?: M3CrawlOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CSP =
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'";
|
||||||
|
|
||||||
|
const COMPRESSIBLE = /^(?:text\/|application\/(?:javascript|json|xml|manifest\+json|wasm)|image\/svg\+xml)/;
|
||||||
|
const HASHED = /[-.][a-z0-9]{8}\.[a-z0-9]+$/i;
|
||||||
|
|
||||||
|
const securityHeaders = (csp: string | false): Record<string, string> => ({
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||||
|
...(csp ? { "Content-Security-Policy": csp } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `no-cache` for HTML, immutable for content-hashed names, an hour for the rest. */
|
||||||
|
export function cacheControl(path: string, type: string): string {
|
||||||
|
if (type.startsWith("text/html")) return "no-cache";
|
||||||
|
return HASHED.test(path) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best encoding the client accepts for a compressible response. */
|
||||||
|
export function pickEncoding(acceptEncoding: string | null): "br" | "gzip" | undefined {
|
||||||
|
const qualities = new Map<string, number>();
|
||||||
|
for (const part of (acceptEncoding ?? "").toLowerCase().split(",")) {
|
||||||
|
const [encoding, ...parameters] = part.trim().split(";");
|
||||||
|
if (!encoding) continue;
|
||||||
|
const quality = parameters.find(parameter => parameter.trim().startsWith("q="));
|
||||||
|
const value = quality === undefined ? 1 : Number(quality.trim().slice(2));
|
||||||
|
qualities.set(encoding, Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0);
|
||||||
|
}
|
||||||
|
const br = qualities.get("br") ?? qualities.get("*") ?? 0;
|
||||||
|
const gzip = qualities.get("gzip") ?? qualities.get("*") ?? 0;
|
||||||
|
return br > 0 && br >= gzip ? "br" : gzip > 0 ? "gzip" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3App {
|
||||||
|
fetch(request: Request): Promise<Response>;
|
||||||
|
/** The API routes, for `Bun.serve({ routes })`. */
|
||||||
|
routes: M3Routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app as a pure `fetch` handler: API, health, info, static files, SPA fallback. */
|
||||||
|
export function createM3App(options: M3AppOptions = {}): M3App {
|
||||||
|
const { name = "m3-app", version = "0.0.0", buildId = "0", files, spa = true, health = "/healthz" } = options;
|
||||||
|
const csp = options.csp === undefined ? DEFAULT_CSP : options.csp;
|
||||||
|
const base = securityHeaders(csp);
|
||||||
|
const api = normalizeApi(options.api);
|
||||||
|
const apiFetch = routesToFetch(api.routes);
|
||||||
|
const started = Date.now();
|
||||||
|
const cache = new Map<string, Uint8Array>();
|
||||||
|
const theme = options.theme === true ? {} : options.theme || undefined;
|
||||||
|
const markdown = options.markdown === true ? {} : options.markdown || undefined;
|
||||||
|
const dynamicHtml = Boolean(options.nonce || theme);
|
||||||
|
const crawl =
|
||||||
|
options.crawl && files ? routesToFetch(crawlRoutes(files.paths(), { name, ...options.crawl })) : undefined;
|
||||||
|
|
||||||
|
/** An HTML response written for this request: its own CSP nonce and its theme. */
|
||||||
|
async function page(body: BodyInit, request: Request, status = 200): Promise<Response> {
|
||||||
|
const nonce = options.nonce ? createNonce() : undefined;
|
||||||
|
let resolved: M3ResolvedTheme | undefined;
|
||||||
|
if (theme) resolved = await resolveTheme(requestCookies(request), theme);
|
||||||
|
const headers = new Headers({
|
||||||
|
...securityHeaders(csp && nonce ? cspWithNonce(csp, nonce) : csp),
|
||||||
|
"Content-Type": "text/html;charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
...(resolved ? { Vary: "Cookie" } : {}),
|
||||||
|
});
|
||||||
|
if (resolved?.setCookie) headers.append("Set-Cookie", resolved.setCookie);
|
||||||
|
if (request.method === "HEAD") return new Response(null, { status, headers });
|
||||||
|
const response = new Response(body, { status, headers });
|
||||||
|
return dynamicHtml
|
||||||
|
? injectHtml(response, { ...(nonce ? { nonce } : {}), ...(resolved ? { theme: resolved } : {}) })
|
||||||
|
: response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200): Response =>
|
||||||
|
Response.json(body, { status, headers: { ...base, "Cache-Control": "no-store" } });
|
||||||
|
|
||||||
|
async function serveFile(path: string, request: Request, status = 200): Promise<Response> {
|
||||||
|
const source = files!;
|
||||||
|
const file = source.file(path);
|
||||||
|
const type = file.type || "application/octet-stream";
|
||||||
|
if (dynamicHtml && type.startsWith("text/html")) return page(file, request, status);
|
||||||
|
const etag = source.etag(path);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...base,
|
||||||
|
"Content-Type": type,
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": cacheControl(path, type),
|
||||||
|
Vary: "Accept-Encoding",
|
||||||
|
};
|
||||||
|
if (status === 200 && request.headers.get("if-none-match") === etag)
|
||||||
|
return new Response(null, { status: 304, headers });
|
||||||
|
const encoding =
|
||||||
|
COMPRESSIBLE.test(type) && file.size >= 1024 ? pickEncoding(request.headers.get("accept-encoding")) : undefined;
|
||||||
|
if (encoding) {
|
||||||
|
const key = `${path}|${encoding}|${etag}`;
|
||||||
|
let bytes = cache.get(key);
|
||||||
|
if (!bytes) {
|
||||||
|
const raw = new Uint8Array(await file.arrayBuffer());
|
||||||
|
bytes =
|
||||||
|
encoding === "br"
|
||||||
|
? brotliCompressSync(raw, { params: { [zlib.BROTLI_PARAM_QUALITY]: 5 } })
|
||||||
|
: Bun.gzipSync(raw);
|
||||||
|
cache.set(key, bytes);
|
||||||
|
}
|
||||||
|
headers["Content-Encoding"] = encoding;
|
||||||
|
headers["Content-Length"] = String(bytes.length);
|
||||||
|
return new Response(request.method === "HEAD" ? null : (bytes as unknown as BodyInit), {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
headers["Content-Length"] = String(file.size);
|
||||||
|
return new Response(request.method === "HEAD" ? null : file, { status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = decodeURIComponent(url.pathname);
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad Request", { status: 400, headers: base });
|
||||||
|
}
|
||||||
|
if (pathname.includes("\0") || pathname.split("/").includes(".."))
|
||||||
|
return new Response("Bad Request", { status: 400, headers: base });
|
||||||
|
|
||||||
|
if (health && pathname === health)
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
uptimeSeconds: Math.round((Date.now() - started) / 1000),
|
||||||
|
});
|
||||||
|
if (pathname === "/api/m3/info") {
|
||||||
|
return json({
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
buildId,
|
||||||
|
bun: Bun.version,
|
||||||
|
files: files?.paths().length ?? 0,
|
||||||
|
api: Object.keys(api.routes),
|
||||||
|
...options.info,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromApi = await apiFetch(request);
|
||||||
|
if (fromApi) return fromApi;
|
||||||
|
if (api.fetch) {
|
||||||
|
const fallback = await api.fetch(request);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOnly = request.method === "GET" || request.method === "HEAD";
|
||||||
|
if (!files || !readOnly) return new Response("Not Found", { status: 404, headers: base });
|
||||||
|
if (crawl) {
|
||||||
|
const crawled = await crawl(request);
|
||||||
|
if (crawled) return crawled;
|
||||||
|
}
|
||||||
|
const path = pathname.endsWith("/") ? `${pathname}index.html` : pathname;
|
||||||
|
const wantsHtml = (request.headers.get("accept") ?? "").includes("text/html");
|
||||||
|
if (markdown) {
|
||||||
|
const doc = path.endsWith(".md") ? path : `${path}.md`;
|
||||||
|
if (files.has(doc) && (wantsHtml || doc !== path))
|
||||||
|
return page(renderMarkdown(await files.file(doc).text(), markdown).html, request);
|
||||||
|
}
|
||||||
|
if (files.has(path)) return serveFile(path, request);
|
||||||
|
if (files.has(`${path}/index.html`)) return serveFile(`${path}/index.html`, request);
|
||||||
|
if (spa && wantsHtml && extname(path) === "" && files.has("/index.html")) return serveFile("/index.html", request);
|
||||||
|
return new Response("Not Found", { status: 404, headers: base });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetch: handle, routes: api.routes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3ServerOptions extends M3AppOptions {
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
/** Development: Bun bundles this HTML import on the fly with hot module reloading (plugins from `bunfig.toml`). */
|
||||||
|
index?: HTMLBundle;
|
||||||
|
hmr?: boolean;
|
||||||
|
/** Seconds before an idle connection is closed (default 30). */
|
||||||
|
idleTimeout?: number;
|
||||||
|
/** Extra native routes, ahead of the API: HTML imports (bundled by Bun), `m3ServerRoutes` of `@aphrody/m3/server`. */
|
||||||
|
routes?: Bun.Serve.Routes<M3SocketData, string>;
|
||||||
|
/** WebSocket topics at `/api/m3/ws` (`server.publish`); see `server-realtime.ts`. */
|
||||||
|
realtime?: boolean | M3RealtimeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PORT`, then the option, then 3000. */
|
||||||
|
export function resolvePort(option?: number): number {
|
||||||
|
const fromEnv = Number(Bun.env.PORT);
|
||||||
|
if (
|
||||||
|
Bun.env.PORT !== undefined &&
|
||||||
|
Bun.env.PORT.trim() !== "" &&
|
||||||
|
Number.isInteger(fromEnv) &&
|
||||||
|
fromEnv >= 0 &&
|
||||||
|
fromEnv <= 65535
|
||||||
|
)
|
||||||
|
return fromEnv;
|
||||||
|
if (option !== undefined && (!Number.isInteger(option) || option < 0 || option > 65535))
|
||||||
|
throw new RangeError(`m3-bun: invalid port ${option}`);
|
||||||
|
return option ?? 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bind the app: API routes natively, the rest (files, health, info) through `createM3App().fetch`. */
|
||||||
|
export function startM3Server(options: M3ServerOptions = {}): Server<M3SocketData> {
|
||||||
|
const app = createM3App(options);
|
||||||
|
const port = resolvePort(options.port);
|
||||||
|
const realtime = options.realtime
|
||||||
|
? realtimeHandlers(options.realtime === true ? {} : options.realtime)
|
||||||
|
: undefined;
|
||||||
|
const common = {
|
||||||
|
port,
|
||||||
|
...(options.hostname ? { hostname: options.hostname } : {}),
|
||||||
|
idleTimeout: options.idleTimeout ?? 30,
|
||||||
|
...(realtime ? { websocket: realtime.websocket } : {}),
|
||||||
|
};
|
||||||
|
const routes: Bun.Serve.Routes<M3SocketData, string> = {
|
||||||
|
...options.routes,
|
||||||
|
...(app.routes as unknown as Bun.Serve.Routes<M3SocketData, string>),
|
||||||
|
...(realtime
|
||||||
|
? {
|
||||||
|
[realtime.path]: async (request: Request, server: Server<M3SocketData>) =>
|
||||||
|
(await realtime.upgrade(request, server)) as Response,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const fetch = (request: Request): Promise<Response> => app.fetch(request);
|
||||||
|
if (options.index) {
|
||||||
|
const hmr = options.hmr ?? true;
|
||||||
|
const health = options.health === false ? undefined : (options.health ?? "/healthz");
|
||||||
|
return Bun.serve<M3SocketData>({
|
||||||
|
...common,
|
||||||
|
development: { hmr, console: hmr },
|
||||||
|
routes: {
|
||||||
|
...routes,
|
||||||
|
...(health ? { [health]: fetch } : {}),
|
||||||
|
"/api/m3/info": fetch,
|
||||||
|
"/*": options.index,
|
||||||
|
},
|
||||||
|
fetch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Serving a build: an uncaught handler error is logged and answers a plain 500, never the development page with
|
||||||
|
// the sources.
|
||||||
|
const csp = options.csp === undefined ? DEFAULT_CSP : options.csp;
|
||||||
|
const error = (cause: Error): Response => {
|
||||||
|
console.error(cause);
|
||||||
|
return new Response("Internal Server Error", { status: 500, headers: securityHeaders(csp) });
|
||||||
|
};
|
||||||
|
return Bun.serve<M3SocketData>({ ...common, development: false, routes, fetch, error });
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Cloudflare. Workers with static assets (`wrangler.jsonc`: the build directory is served by the platform, the
|
||||||
|
// Worker answers /api/* and /healthz through `routesToFetch`, SPA fallback on) and a Pages variant (`_headers`,
|
||||||
|
// `_redirects`, one catch-all function). The API is the same Bun routes module the other targets use; Workers
|
||||||
|
// cannot run Bun APIs, so a route that needs `Bun.file` or `bun:sqlite` must be rewritten for the Worker.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
export const wranglerJsonc = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": ${JSON.stringify(c.name)},
|
||||||
|
"main": "src/worker.ts",
|
||||||
|
"compatibility_date": "2026-10-01",
|
||||||
|
"assets": {
|
||||||
|
"directory": ${JSON.stringify(`./${c.outdir}`)},
|
||||||
|
"binding": "ASSETS",
|
||||||
|
"not_found_handling": "single-page-application",
|
||||||
|
"run_worker_first": ["/api/*", "/healthz"]
|
||||||
|
}${c.domain ? `,\n "routes": [{ "pattern": ${JSON.stringify(c.domain)}, "custom_domain": true }]` : ""}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const workerSource = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
// Cloudflare Worker: /api/* and /healthz from the app's routes, everything else from the static assets.
|
||||||
|
import { normalizeApi, routesToFetch } from "@aphrody/m3-bun/api";
|
||||||
|
${c.api ? `import api from ${JSON.stringify(`../${c.api}`)};` : "const api = {};"}
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
ASSETS: { fetch(request: Request): Promise<Response> };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = routesToFetch(normalizeApi(api).routes);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const { pathname } = new URL(request.url);
|
||||||
|
if (pathname === "/healthz") return Response.json({ ok: true, name: ${JSON.stringify(c.name)}, version: ${JSON.stringify(c.version)} });
|
||||||
|
const response = await handler(request);
|
||||||
|
return response ?? env.ASSETS.fetch(request);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const pagesFunction = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
// Cloudflare Pages Functions: functions/[[path]].ts answers /api/* and /healthz; static files are served by Pages first.
|
||||||
|
import { normalizeApi, routesToFetch } from "@aphrody/m3-bun/api";
|
||||||
|
${c.api ? `import api from ${JSON.stringify(`../${c.api}`)};` : "const api = {};"}
|
||||||
|
|
||||||
|
const handler = routesToFetch(normalizeApi(api).routes);
|
||||||
|
|
||||||
|
export const onRequest = async ({ request, next }: { request: Request; next(): Promise<Response> }): Promise<Response> => {
|
||||||
|
const { pathname } = new URL(request.url);
|
||||||
|
if (pathname === "/healthz") return Response.json({ ok: true, name: ${JSON.stringify(c.name)}, version: ${JSON.stringify(c.version)} });
|
||||||
|
return (await handler(request)) ?? next();
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const cloudflare: Target = {
|
||||||
|
id: "cloudflare",
|
||||||
|
title: "Cloudflare",
|
||||||
|
description:
|
||||||
|
"Workers with static assets (wrangler.jsonc, src/worker.ts) and a Pages variant (_headers, _redirects, functions/[[path]].ts)",
|
||||||
|
generate(c): GeneratedFile[] {
|
||||||
|
return [
|
||||||
|
{ path: "wrangler.jsonc", content: wranglerJsonc(c) },
|
||||||
|
{ path: "src/worker.ts", content: workerSource(c) },
|
||||||
|
{
|
||||||
|
path: "pages/_headers",
|
||||||
|
content: `# ${GENERATED_NOTE}\n/*\n X-Content-Type-Options: nosniff\n Referrer-Policy: strict-origin-when-cross-origin\n/chunk-*\n Cache-Control: public, max-age=31536000, immutable\n/index.html\n Cache-Control: no-cache\n`,
|
||||||
|
},
|
||||||
|
{ path: "pages/_redirects", content: `# ${GENERATED_NOTE}\n/* /index.html 200\n` },
|
||||||
|
{ path: "pages/functions/[[path]].ts", content: pagesFunction(c) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Coolify: a Docker Compose application. Coolify reads `SERVICE_FQDN_<SERVICE>_<PORT>` to generate (or take) the
|
||||||
|
// domain and route it to that port, so the compose file carries no proxy labels. The build uses the Dockerfile
|
||||||
|
// of the docker target. Import the repository as a "Docker Compose" resource and point it at this file.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
export const composeFile = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
services:
|
||||||
|
${c.name}:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- SERVICE_FQDN_${c.name.toUpperCase().replace(/-/g, "_")}_${c.port}${c.domain ? `=https://${c.domain}` : ""}
|
||||||
|
- PORT=${c.port}
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/app/server", "--healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const coolify: Target = {
|
||||||
|
id: "coolify",
|
||||||
|
title: "Coolify",
|
||||||
|
description:
|
||||||
|
"Docker Compose resource with Coolify's SERVICE_FQDN routing variable and the executable's health check",
|
||||||
|
generate: (c): GeneratedFile[] => [
|
||||||
|
{ path: "docker-compose.yaml", content: composeFile(c) },
|
||||||
|
{
|
||||||
|
path: ".env.example",
|
||||||
|
content: `# ${GENERATED_NOTE}\n# Set secrets in Coolify's environment panel, not here.\nPORT=${c.port}\nHOST=0.0.0.0\n`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Docker: a multi-stage Dockerfile that compiles the app on Aphrody Alpine (musl) and runs the executable on
|
||||||
|
// ghcr.io/aphrody-labs/alpine:3.24-runtime (musl, libstdc++, CA certificates, tzdata, user agent 1000; built by
|
||||||
|
// scripts/aphrody/alpine/runtime.Dockerfile of aphrody-labs/bun). Plus a Dockerfile for a rootfs prepared by
|
||||||
|
// `m3-bun package --rootfs` (the smallest image: scratch plus the executable's libraries), a compose file and a
|
||||||
|
// .dockerignore.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
/** Base image of the build and run stages. */
|
||||||
|
export const RUNTIME_IMAGE = "ghcr.io/aphrody-labs/alpine:3.24-runtime";
|
||||||
|
|
||||||
|
const dockerfile = (c: TargetContext): string => `# syntax=docker/dockerfile:1
|
||||||
|
# ${GENERATED_NOTE}
|
||||||
|
# ${c.name} ${c.version}: one executable serves the UI and the API. Both stages use the Aphrody Alpine runtime
|
||||||
|
# image, so the executable is compiled by its musl Bun for the build platform (amd64 or arm64) and finds musl,
|
||||||
|
# libstdc++, CA certificates and tzdata at run time. It runs as the unprivileged user agent (1000).
|
||||||
|
# For the smallest image use Dockerfile.scratch.
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE} AS build
|
||||||
|
WORKDIR /home/agent/src
|
||||||
|
COPY --chown=agent:agent . .
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN ${c.cli} compile --outfile /home/agent/out/${c.binary}
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
COPY --from=build /home/agent/out/${c.binary} /app/server
|
||||||
|
ENV PORT=${c.port} HOST=0.0.0.0
|
||||||
|
EXPOSE ${c.port}
|
||||||
|
HEALTHCHECK --interval=30s --timeout=4s --start-period=5s --retries=3 CMD ["/app/server", "--healthcheck"]
|
||||||
|
ENTRYPOINT ["/app/server"]
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scratchfile = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# Image of an already compiled app: \`${c.cli} package --rootfs\` writes ./rootfs (the executable and its libraries).
|
||||||
|
# docker build -f Dockerfile.scratch -t ${c.image}:${c.version} .
|
||||||
|
FROM scratch
|
||||||
|
COPY rootfs/ /
|
||||||
|
ENV PORT=${c.port} HOST=0.0.0.0
|
||||||
|
EXPOSE ${c.port}
|
||||||
|
USER 10001:10001
|
||||||
|
HEALTHCHECK --interval=30s --timeout=4s --start-period=5s --retries=3 CMD ["/app/server", "--healthcheck"]
|
||||||
|
ENTRYPOINT ["/app/server"]
|
||||||
|
`;
|
||||||
|
|
||||||
|
const compose = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
services:
|
||||||
|
${c.name}:
|
||||||
|
image: ${c.image}:${c.version}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${c.port}:${c.port}"
|
||||||
|
environment:
|
||||||
|
PORT: "${c.port}"
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/app/server", "--healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const docker: Target = {
|
||||||
|
id: "docker",
|
||||||
|
title: "Docker",
|
||||||
|
description:
|
||||||
|
"Multi-stage Dockerfile on the Aphrody Alpine runtime image, a scratch Dockerfile for the rootfs, compose and .dockerignore",
|
||||||
|
generate(c): GeneratedFile[] {
|
||||||
|
return [
|
||||||
|
{ path: "Dockerfile", content: dockerfile(c) },
|
||||||
|
{ path: "Dockerfile.scratch", content: scratchfile(c) },
|
||||||
|
{ path: "docker-compose.yml", content: compose(c) },
|
||||||
|
{
|
||||||
|
path: ".dockerignore",
|
||||||
|
content: ["node_modules", ".git", "dist", "bin", "deploy", "**/*.log", ".env*", ""].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Fly.io: fly.toml for the Dockerfile of the docker target (copy both to the project root). The image is the
|
||||||
|
// Aphrody Alpine runtime image with the one executable; Fly's HTTP check uses the app's own /healthz.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
export const flyToml = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
app = "${c.name}"
|
||||||
|
primary_region = "${c.region}"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
PORT = "${c.port}"
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = ${c.port}
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = "stop"
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
|
||||||
|
[http_service.concurrency]
|
||||||
|
type = "requests"
|
||||||
|
soft_limit = 200
|
||||||
|
hard_limit = 250
|
||||||
|
|
||||||
|
[[http_service.checks]]
|
||||||
|
grace_period = "5s"
|
||||||
|
interval = "15s"
|
||||||
|
method = "GET"
|
||||||
|
path = "/healthz"
|
||||||
|
timeout = "3s"
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
size = "shared-cpu-1x"
|
||||||
|
memory = "256mb"
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const fly: Target = {
|
||||||
|
id: "fly",
|
||||||
|
title: "Fly.io",
|
||||||
|
description:
|
||||||
|
"fly.toml: HTTP service with /healthz check, auto stop and start, built from the docker target's Dockerfile",
|
||||||
|
generate: (c): GeneratedFile[] => [{ path: "fly.toml", content: flyToml(c) }],
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The deploy targets of m3-bun. Every target is a function from a `TargetContext` to files; `generateTargets`
|
||||||
|
// writes them below `<out>/<id>/` and nothing else. No target applies anything: no docker push, no flyctl, no
|
||||||
|
// wrangler deploy, no systemctl, no nginx reload, no tauri build, no registry write, no theme install,
|
||||||
|
// no Lambda publish, no GitHub release.
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { yoloRoot, writeFiles, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { M3App } from "../config.ts";
|
||||||
|
import { cloudflare } from "./cloudflare.ts";
|
||||||
|
import { coolify } from "./coolify.ts";
|
||||||
|
import { docker } from "./docker.ts";
|
||||||
|
import { fly } from "./fly.ts";
|
||||||
|
import { lambda } from "./lambda.ts";
|
||||||
|
import { linuxImage } from "./linux-image.ts";
|
||||||
|
import { next } from "./next.ts";
|
||||||
|
import { nginx } from "./nginx.ts";
|
||||||
|
import { osThemes } from "./os-themes.ts";
|
||||||
|
import { release } from "./release.ts";
|
||||||
|
import { systemd } from "./systemd.ts";
|
||||||
|
import { tauri } from "./tauri.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
import { vercel } from "./vercel.ts";
|
||||||
|
import { vscode } from "./vscode.ts";
|
||||||
|
import { webos } from "./webos.ts";
|
||||||
|
import { windows } from "./windows.ts";
|
||||||
|
|
||||||
|
export type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
export { inspectWindows, type WindowsInspection } from "./windows-inspect.ts";
|
||||||
|
export { verifyWebView, type VerifyWebViewOptions, type WebViewReport } from "./webos.ts";
|
||||||
|
export { windowsIcon } from "./windows.ts";
|
||||||
|
|
||||||
|
export const TARGETS: readonly Target[] = [
|
||||||
|
docker,
|
||||||
|
systemd,
|
||||||
|
nginx,
|
||||||
|
vercel,
|
||||||
|
fly,
|
||||||
|
cloudflare,
|
||||||
|
coolify,
|
||||||
|
vscode,
|
||||||
|
windows,
|
||||||
|
linuxImage,
|
||||||
|
tauri,
|
||||||
|
webos,
|
||||||
|
lambda,
|
||||||
|
osThemes,
|
||||||
|
release,
|
||||||
|
next,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTarget(id: string): Target {
|
||||||
|
const target = TARGETS.find((t) => t.id === id);
|
||||||
|
if (!target)
|
||||||
|
throw new Error(`m3-bun: unknown deploy target ${id} (${TARGETS.map((t) => t.id).join(", ")})`);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The context of `app`, with the deploy section of its config applied. */
|
||||||
|
export function contextFor(app: M3App, overrides: Partial<TargetContext> = {}): TargetContext {
|
||||||
|
const deploy = app.bun.deploy ?? {};
|
||||||
|
const relativeApi = app.api ? app.api.slice(app.dir.length + 1).replaceAll("\\", "/") : undefined;
|
||||||
|
const theme = app.m3.theme ?? {};
|
||||||
|
return {
|
||||||
|
name: app.name,
|
||||||
|
version: app.version,
|
||||||
|
port: app.port,
|
||||||
|
...(deploy.domain ? { domain: deploy.domain } : {}),
|
||||||
|
image: deploy.image ?? `ghcr.io/aphrody-labs/${app.name}`,
|
||||||
|
namespace: deploy.namespace ?? "aphrody",
|
||||||
|
user: deploy.user ?? "ubuntu",
|
||||||
|
region: deploy.region ?? "cdg",
|
||||||
|
binary: app.name,
|
||||||
|
outdir: app.outdir.slice(app.dir.length + 1).replaceAll("\\", "/") || "dist",
|
||||||
|
...(relativeApi ? { api: relativeApi } : {}),
|
||||||
|
bunVersion: Bun.version,
|
||||||
|
yoloRoot: yoloRoot(),
|
||||||
|
cli: "bunx --bun m3-bun",
|
||||||
|
tls: "letsencrypt",
|
||||||
|
...(app.bun.compile?.windows ? { windows: app.bun.compile.windows } : {}),
|
||||||
|
appDir: app.dir,
|
||||||
|
...(theme.seed ? { seed: theme.seed } : {}),
|
||||||
|
...(theme.variant ? { variant: theme.variant } : {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedTarget {
|
||||||
|
id: string;
|
||||||
|
dir: string;
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate `ids` (or every target) into `<out>/<id>/`. Returns what was written. */
|
||||||
|
export async function generateTargets(
|
||||||
|
ids: readonly string[],
|
||||||
|
context: TargetContext,
|
||||||
|
out: string,
|
||||||
|
): Promise<GeneratedTarget[]> {
|
||||||
|
const targets = ids.length === 0 || ids.includes("all") ? TARGETS : ids.map(getTarget);
|
||||||
|
const generated: GeneratedTarget[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
const dir = resolve(join(out, target.id));
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Target generators may build artifacts; complete one target before starting the next.
|
||||||
|
const files = await target.generate({ ...context, targetDir: dir });
|
||||||
|
// `writeFiles` hands the content to `Bun.write`, which takes bytes as well as text.
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Persist a target before proceeding so failures cannot leave later targets partially generated.
|
||||||
|
generated.push({ id: target.id, dir, files: await writeFiles(dir, files as GeneratedFile[]) });
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// AWS Lambda through the Bun runtime layer of the Bun fork (packages/bun-lambda): its `bootstrap` runs
|
||||||
|
// `runtime.ts`, which imports `$LAMBDA_TASK_ROOT/<file>` named by `_HANDLER` (`handler.fetch`) and turns every
|
||||||
|
// API Gateway or function URL event into a `Request` for the module's `fetch`. The generated handler is the same
|
||||||
|
// `createM3App` the compiled executable serves (UI files, API, /healthz), bundled into one file.
|
||||||
|
import { relative } from "node:path";
|
||||||
|
import { GENERATED_NOTE } from "../util.ts";
|
||||||
|
import { forkTag } from "./release.ts";
|
||||||
|
import type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
|
||||||
|
/** The app directory seen from the target directory (`../..` for the default `deploy/lambda`). */
|
||||||
|
export const appFromTarget = (c: TargetContext): string =>
|
||||||
|
c.appDir && c.targetDir ? relative(c.targetDir, c.appDir).replaceAll("\\", "/") || "." : "../..";
|
||||||
|
|
||||||
|
export const handlerSource = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
// The Lambda handler of ${c.name}: \`_HANDLER=handler.fetch\` for the Bun runtime layer (packages/bun-lambda).
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createM3App, dirSource } from "@aphrody/m3-bun/server";
|
||||||
|
${c.api ? `import api from "${appFromTarget(c)}/${c.api}";\n` : ""}
|
||||||
|
const app = createM3App({
|
||||||
|
name: ${JSON.stringify(c.name)},
|
||||||
|
version: ${JSON.stringify(c.version)},
|
||||||
|
files: dirSource(join(import.meta.dir, ${JSON.stringify(c.outdir)})),
|
||||||
|
${c.api ? " api,\n" : ""}});
|
||||||
|
|
||||||
|
export default { fetch: app.fetch };
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const buildScript = (c: TargetContext): string => `#!/usr/bin/env bun
|
||||||
|
// ${GENERATED_NOTE}
|
||||||
|
// Build the function package lambda/function.zip: the UI (${c.cli} build), handler.ts bundled for Bun into
|
||||||
|
// handler.js, and the UI files next to it.
|
||||||
|
import { cp, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { archiveDir } from "@aphrody/m3-bun";
|
||||||
|
|
||||||
|
const here = import.meta.dir;
|
||||||
|
const app = join(here, ${JSON.stringify(appFromTarget(c))});
|
||||||
|
const stage = join(here, "out");
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const ui = Bun.spawnSync([...${JSON.stringify(c.cli.split(" "))}, "build"], { windowsHide: true, cwd: app, stdout: "inherit", stderr: "inherit" });
|
||||||
|
if (ui.exitCode !== 0) throw new Error("${c.cli} build failed");
|
||||||
|
|
||||||
|
const bundle = await Bun.build({ entrypoints: [join(here, "handler.ts")], outdir: stage, target: "bun", minify: true, naming: "handler.js" });
|
||||||
|
if (!bundle.success) throw new AggregateError(bundle.logs, "handler bundle failed");
|
||||||
|
await cp(join(app, ${JSON.stringify(c.outdir)}), join(stage, ${JSON.stringify(c.outdir)}), { recursive: true });
|
||||||
|
|
||||||
|
const archiver = await archiveDir(stage, join(here, "function.zip"), "zip");
|
||||||
|
console.log(\`function.zip written by \${archiver}\`);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const layerScript = (c: TargetContext): string => {
|
||||||
|
const tag = forkTag(c.bunVersion);
|
||||||
|
const url = tag
|
||||||
|
? `https://github.com/aphrody-labs/bun/releases/download/${tag}/bun-linux-aarch64.zip`
|
||||||
|
: undefined;
|
||||||
|
const source = url ? `--url "\${BUN_LAYER_URL:-${url}}"` : `--release "bun-v${c.bunVersion}"`;
|
||||||
|
return `#!/bin/sh
|
||||||
|
# ${GENERATED_NOTE}
|
||||||
|
# Build the Bun runtime layer with the build-layer command of packages/bun-lambda in a Bun fork checkout
|
||||||
|
# (APHRODY_BUN_CHECKOUT, run \`bun install\` in packages/bun-lambda once). Publishing stays manual:
|
||||||
|
# aws lambda publish-layer-version --layer-name bun --zip-file fileb://bun-lambda-layer.zip --compatible-architectures arm64
|
||||||
|
set -eu
|
||||||
|
checkout="\${APHRODY_BUN_CHECKOUT:?set APHRODY_BUN_CHECKOUT to a checkout of aphrody-labs/bun}"
|
||||||
|
bun "$checkout/packages/bun-lambda/scripts/build-layer.ts" --arch aarch64 \\
|
||||||
|
${source} \\
|
||||||
|
--output "$(dirname "$0")/bun-lambda-layer.zip"
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const samTemplate = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# AWS SAM: ${c.name} as one arm64 function behind a function URL. \`sam deploy --guided\` after build-lambda.ts.
|
||||||
|
AWSTemplateFormatVersion: "2010-09-09"
|
||||||
|
Transform: AWS::Serverless-2016-10-31
|
||||||
|
Parameters:
|
||||||
|
BunLayerArn:
|
||||||
|
Type: String
|
||||||
|
Description: ARN of the layer built by build-layer.sh
|
||||||
|
Resources:
|
||||||
|
App:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: ${c.name}
|
||||||
|
CodeUri: function.zip
|
||||||
|
Handler: handler.fetch
|
||||||
|
Runtime: provided.al2023
|
||||||
|
Architectures: [arm64]
|
||||||
|
MemorySize: 512
|
||||||
|
Timeout: 30
|
||||||
|
Layers: [!Ref BunLayerArn]
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
NODE_ENV: production
|
||||||
|
FunctionUrlConfig:
|
||||||
|
AuthType: NONE
|
||||||
|
Outputs:
|
||||||
|
Url:
|
||||||
|
Value: !GetAtt AppUrl.FunctionUrl
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const lambda: Target = {
|
||||||
|
id: "lambda",
|
||||||
|
title: "AWS Lambda",
|
||||||
|
description:
|
||||||
|
"handler.ts (createM3App for the bun-lambda runtime), build-lambda.ts (function.zip), build-layer.sh (bun-lambda build-layer), SAM template",
|
||||||
|
generate(c): TargetFile[] {
|
||||||
|
return [
|
||||||
|
{ path: "handler.ts", content: handlerSource(c) },
|
||||||
|
{ path: "build-lambda.ts", content: buildScript(c), mode: 0o755 },
|
||||||
|
{ path: "build-layer.sh", content: layerScript(c), mode: 0o755 },
|
||||||
|
{ path: "template.yaml", content: samTemplate(c) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// A minimal Linux image for the app, built by `scripts/aphrody/initramfs.ts` of the aphrody-labs/bun fork: the
|
||||||
|
// compiled executable is the only Bun in the image. Installed as /bin/bun it is plain Bun (the fork treats a compiled
|
||||||
|
// executable called `bun` as the engine) and runs /init, the fork's TypeScript PID 1 on `bun:linux` (mounts, kernel
|
||||||
|
// modules, network over rtnetlink with a static address or DHCP, orphan reaping, power-off); /init then starts the
|
||||||
|
// same file under the app's name, which boots the compiled app.
|
||||||
|
//
|
||||||
|
// Honest limits, measured with `bun build --compile` (Bun 1.4): the executable is NOT static. The glibc targets link
|
||||||
|
// libc, libm, libdl, libpthread and the loader; the musl targets link musl and libstdc++. initramfs.ts copies them
|
||||||
|
// (PT_INTERP and DT_NEEDED read from the ELF, resolved under SYSROOT). The executable must come from a fork build that
|
||||||
|
// has `bun:linux`; build-image.sh checks it before assembling anything.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
export const INITRAMFS_SCRIPTS = ["initramfs.ts", "initramfs-init.ts", "initramfs-net.ts"] as const;
|
||||||
|
|
||||||
|
export const buildScript = (c: TargetContext): string => `#!/usr/bin/env bash
|
||||||
|
# ${GENERATED_NOTE}
|
||||||
|
# Assemble the ${c.name} image from a compiled Linux executable:
|
||||||
|
# ${c.cli} compile --target linux-x64 --outfile bin/${c.binary}
|
||||||
|
# ./build-image.sh bin/${c.binary} [outdir] # -> <outdir>/initramfs.cpio.gz
|
||||||
|
# Environment:
|
||||||
|
# BUN_SRC aphrody-labs/bun checkout providing scripts/aphrody/initramfs*.ts (default: fetched from BUN_REF)
|
||||||
|
# BUN_REF git ref fetched when BUN_SRC is unset (default: main)
|
||||||
|
# INITRAMFS_ARGS extra initramfs.ts arguments, split on spaces (e.g. --file host:/target)
|
||||||
|
# SYSROOT where the executable's loader and libraries are taken from (default: /)
|
||||||
|
# M3_IP dhcp, or a static address with prefix (default: 10.0.2.15/24, QEMU user-mode network)
|
||||||
|
# M3_GW, M3_DNS gateway and resolver of a static address (default: 10.0.2.2 and 10.0.2.3)
|
||||||
|
# MODULES kernel modules loaded before the network, dependencies first (default: failover, net_failover and
|
||||||
|
# virtio_net of /lib/modules/$(uname -r) when they are modules there)
|
||||||
|
set -euo pipefail
|
||||||
|
BIN=\${1:?usage: build-image.sh <compiled-linux-executable> [outdir]}
|
||||||
|
OUT=\${2:-image}
|
||||||
|
SYSROOT=\${SYSROOT:-/}
|
||||||
|
M3_IP=\${M3_IP:-10.0.2.15/24}
|
||||||
|
|
||||||
|
if ! BUN_BE_BUN=1 "$BIN" -e 'require("bun:linux")' >/dev/null 2>&1; then
|
||||||
|
echo "$BIN has no bun:linux: compile it with an aphrody-labs/bun build that has it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "\${BUN_SRC:-}" ]; then
|
||||||
|
SRC="$BUN_SRC/scripts/aphrody"
|
||||||
|
else
|
||||||
|
SRC=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$SRC"' EXIT
|
||||||
|
for f in ${INITRAMFS_SCRIPTS.join(" ")}; do
|
||||||
|
curl -fsSL -o "$SRC/$f" "https://raw.githubusercontent.com/aphrody-labs/bun/\${BUN_REF:-main}/scripts/aphrody/$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "\${MODULES+x}" ]; then
|
||||||
|
MODULES=""
|
||||||
|
for m in kernel/net/core/failover kernel/drivers/net/net_failover kernel/drivers/net/virtio/virtio_net kernel/drivers/net/virtio_net; do
|
||||||
|
for f in "/lib/modules/$(uname -r)/$m".ko*; do [ -e "$f" ] && MODULES="$MODULES $f"; done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
args=(--bun "$BIN" --argv0 ${c.binary} --sysroot "$SYSROOT" --hostname ${c.name} --ip "$M3_IP")
|
||||||
|
if [ "$M3_IP" != dhcp ]; then args+=(--gateway "\${M3_GW:-10.0.2.2}" --dns "\${M3_DNS:-10.0.2.3}"); fi
|
||||||
|
for m in $MODULES; do args+=(--module "$m"); done
|
||||||
|
args+=(--env PORT=${c.port} --env HOST=0.0.0.0)
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
args+=(\${INITRAMFS_ARGS:-})
|
||||||
|
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
bun "$SRC/initramfs.ts" "\${args[@]}" --out "$OUT/initramfs.cpio.gz" -- /bin/bun
|
||||||
|
du -sh "$OUT/initramfs.cpio.gz" | sed 's/^/size: /'
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const qemuScript = (c: TargetContext): string => `#!/usr/bin/env bash
|
||||||
|
# ${GENERATED_NOTE}
|
||||||
|
# Boot the image in QEMU and forward host port 8080 to the app. KERNEL must be a Linux bzImage with an initramfs,
|
||||||
|
# devtmpfs and virtio networking, built in or passed to build-image.sh as MODULES from the same kernel release.
|
||||||
|
# Bun needs the AVX2-era instruction set: QEMU's default CPU lacks it (the app dies with "invalid opcode"), so the
|
||||||
|
# script uses the host CPU under KVM and \`-cpu max\` under software emulation (slower, but boots in seconds).
|
||||||
|
set -euo pipefail
|
||||||
|
KERNEL=\${KERNEL:-/boot/vmlinuz-$(uname -r)}
|
||||||
|
INITRD=\${1:-image/initramfs.cpio.gz}
|
||||||
|
[ -r "$KERNEL" ] || { echo "set KERNEL to a readable Linux kernel image (not found: $KERNEL)" >&2; exit 1; }
|
||||||
|
if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then CPU=(-enable-kvm -cpu host); else CPU=(-cpu "\${QEMU_CPU:-max}"); fi
|
||||||
|
exec qemu-system-x86_64 -m 512 -nographic -no-reboot "\${CPU[@]}" \\
|
||||||
|
-kernel "$KERNEL" -initrd "$INITRD" \\
|
||||||
|
-append "console=ttyS0 panic=1 quiet" \\
|
||||||
|
-nic user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:\${HOST_PORT:-8080}-:${c.port}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const readme = (c: TargetContext): string => `# ${c.name}: minimal Linux image
|
||||||
|
|
||||||
|
${GENERATED_NOTE}
|
||||||
|
|
||||||
|
What it is: an initramfs built by \`scripts/aphrody/initramfs.ts\` of the aphrody-labs/bun fork. It holds \`/bin/bun\`
|
||||||
|
(the compiled ${c.name}, the only Bun in the image), \`/init\` (the fork's TypeScript PID 1 on \`bun:linux\`), the shared
|
||||||
|
libraries of the executable and, when needed, the kernel's network modules. \`/init\` mounts the pseudo file systems,
|
||||||
|
loads the modules, configures the network (static address or DHCP, default route, \`/etc/resolv.conf\`), starts the app
|
||||||
|
with PORT=${c.port} and HOST=0.0.0.0, and powers the machine off when it exits.
|
||||||
|
|
||||||
|
What it is not: a one-file or "no libc" image. \`bun build --compile\` produces a dynamic executable: glibc targets need
|
||||||
|
libc, libm, libdl, libpthread and the loader; \`-musl\` targets need musl and libstdc++. Bun publishes no static build, so the
|
||||||
|
libraries ship in the image. The executable must be compiled by a fork build that has \`bun:linux\`. The network settings
|
||||||
|
are fixed when the image is built (\`M3_IP\`, \`M3_GW\`, \`M3_DNS\` of build-image.sh). For a container, use the docker
|
||||||
|
target.
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
${c.cli} compile --target linux-x64 --outfile bin/${c.binary}
|
||||||
|
./build-image.sh bin/${c.binary} # -> image/initramfs.cpio.gz
|
||||||
|
KERNEL=/boot/vmlinuz-$(uname -r) ./run-qemu.sh
|
||||||
|
curl -s http://127.0.0.1:8080/healthz
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const linuxImage: Target = {
|
||||||
|
id: "linux-image",
|
||||||
|
title: "Minimal Linux image",
|
||||||
|
description:
|
||||||
|
"initramfs from the fork's scripts/aphrody/initramfs.ts: the executable as the only Bun, the bun:linux PID 1, its libraries; QEMU runner; README with the honest limits",
|
||||||
|
generate(c): GeneratedFile[] {
|
||||||
|
return [
|
||||||
|
{ path: "README.md", content: readme(c) },
|
||||||
|
{ path: "build-image.sh", content: buildScript(c), mode: 0o755 },
|
||||||
|
{ path: "run-qemu.sh", content: qemuScript(c), mode: 0o755 },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Next.js on Bun for the app's M3 front end: next.config.ts composes `withM3` (@aphrody/m3-next: transpiled M3
|
||||||
|
// packages, barrel imports, standalone-friendly externals) inside `withBun` (@aphrody/next-bun: Next and its
|
||||||
|
// Turbopack workers on Bun, one copy of deduplicated packages). build-next.sh runs `next-bun build`, flattens the
|
||||||
|
// standalone tree with `next-bun standalone` (Bun's isolated linker leaves symlinks in it) and copies the static
|
||||||
|
// assets in; the result starts with `bun server.js`.
|
||||||
|
import { GENERATED_NOTE } from "../util.ts";
|
||||||
|
import type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
|
||||||
|
export const nextConfig = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
// ${c.name} on Next.js and Bun. Copy next to package.json; needs next, @aphrody/m3-next and @aphrody/next-bun.
|
||||||
|
import { withM3 } from "@aphrody/m3-next";
|
||||||
|
import { withBun } from "@aphrody/next-bun";
|
||||||
|
|
||||||
|
export default withBun(
|
||||||
|
withM3({
|
||||||
|
output: "standalone",
|
||||||
|
poweredByHeader: false,
|
||||||
|
env: { M3_APP_VERSION: ${JSON.stringify(c.version)} },
|
||||||
|
}),
|
||||||
|
{ bundler: "turbopack", projectDir: import.meta.dirname, dedupe: ["react", "react-dom"] },
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const buildScript = (c: TargetContext): string => `#!/bin/sh
|
||||||
|
# ${GENERATED_NOTE}
|
||||||
|
# Build ${c.name} with Next.js on Bun into .next/standalone (self-contained), from the app directory.
|
||||||
|
set -eu
|
||||||
|
bunx --bun next-bun build --turbopack
|
||||||
|
bunx --bun next-bun standalone .next/standalone
|
||||||
|
mkdir -p .next/standalone/.next
|
||||||
|
cp -R .next/static .next/standalone/.next/static
|
||||||
|
[ -d public ] && cp -R public .next/standalone/public
|
||||||
|
echo "start: PORT=${c.port} bun .next/standalone/server.js"
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const next: Target = {
|
||||||
|
id: "next",
|
||||||
|
title: "Next.js on Bun",
|
||||||
|
description:
|
||||||
|
"next.config.ts (withBun(withM3(...)) from @aphrody/next-bun and @aphrody/m3-next, standalone output) and build-next.sh",
|
||||||
|
generate(c): TargetFile[] {
|
||||||
|
return [
|
||||||
|
{ path: "next.config.ts", content: nextConfig(c) },
|
||||||
|
{ path: "build-next.sh", content: buildScript(c), mode: 0o755 },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// nginx: a vhost in the shape of the yolo vhosts (`tools/config/host/nginx/<ns>/*.conf`: an `upstream` with keepalive,
|
||||||
|
// ACME and redirect on :80, TLS on :443, `include snippets/proxy-client-ip.inc`), proxying everything to the one
|
||||||
|
// Bun process; an HTTP-only variant for a loopback test; and the `routes.json` entry of yolo's route catalog.
|
||||||
|
// Static files and API share the upstream: the app server already sets cache headers and compresses.
|
||||||
|
//
|
||||||
|
// The vhosts are described as the typed model of `crates/infra/infra/src/nginx.rs` (`NginxConfig`) and rendered by
|
||||||
|
// `aphrody infra nginx render`, the single nginx writer of the workspace. The models are always written; without
|
||||||
|
// that command on PATH the `.conf` files are replaced by the command that renders them.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
/** `NginxConfig` of `crates/infra/infra/src/nginx.rs`, as its serde JSON. */
|
||||||
|
export interface NginxModel {
|
||||||
|
upstreams: {
|
||||||
|
name: string;
|
||||||
|
targets: { type: "Tcp" | "UnixSocket"; target: string }[];
|
||||||
|
keepalive?: number;
|
||||||
|
}[];
|
||||||
|
servers: {
|
||||||
|
listen_ports: string[];
|
||||||
|
server_names?: string[];
|
||||||
|
ssl_certificate?: string;
|
||||||
|
ssl_certificate_key?: string;
|
||||||
|
custom_directives?: string[];
|
||||||
|
locations?: {
|
||||||
|
path: string;
|
||||||
|
proxy_pass?: string;
|
||||||
|
proxy_http_version?: string;
|
||||||
|
headers?: [string, string][];
|
||||||
|
custom_directives?: string[];
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamName = (c: TargetContext) => c.name.replace(/-/g, "_");
|
||||||
|
const hostOf = (c: TargetContext) => c.domain ?? `${c.name}.example.com`;
|
||||||
|
|
||||||
|
const upstream = (c: TargetContext): NginxModel["upstreams"][number] => ({
|
||||||
|
name: upstreamName(c),
|
||||||
|
targets: [{ type: "Tcp", target: `127.0.0.1:${c.port}` }],
|
||||||
|
keepalive: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyLocation = (c: TargetContext, scheme: "http" | "https") => ({
|
||||||
|
path: "/",
|
||||||
|
proxy_pass: `http://${upstreamName(c)}`,
|
||||||
|
proxy_http_version: "1.1",
|
||||||
|
headers: [
|
||||||
|
["Connection", '""'],
|
||||||
|
["Host", "$host"],
|
||||||
|
] as [string, string][],
|
||||||
|
custom_directives: [
|
||||||
|
"include snippets/proxy-client-ip.inc",
|
||||||
|
`proxy_set_header X-Forwarded-Proto ${scheme}`,
|
||||||
|
"proxy_read_timeout 30s",
|
||||||
|
"proxy_send_timeout 30s",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** TLS vhost: ACME and redirect on :80, TLS on :443. */
|
||||||
|
export const vhostModel = (c: TargetContext): NginxModel => {
|
||||||
|
const host = hostOf(c);
|
||||||
|
const tls =
|
||||||
|
c.tls === "letsencrypt"
|
||||||
|
? {
|
||||||
|
ssl_certificate: `/etc/letsencrypt/live/${host}/fullchain.pem`,
|
||||||
|
ssl_certificate_key: `/etc/letsencrypt/live/${host}/privkey.pem`,
|
||||||
|
custom: [
|
||||||
|
"include /etc/letsencrypt/options-ssl-nginx.conf",
|
||||||
|
"ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { ssl_certificate: c.tls.cert, ssl_certificate_key: c.tls.key, custom: [] };
|
||||||
|
const listen = c.http2 === "listen" ? "443 ssl http2" : "443 ssl";
|
||||||
|
return {
|
||||||
|
upstreams: [upstream(c)],
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
listen_ports: ["80"],
|
||||||
|
server_names: [host],
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
path: "^~ /.well-known/acme-challenge/",
|
||||||
|
custom_directives: [
|
||||||
|
"root /var/www/letsencrypt",
|
||||||
|
"default_type text/plain",
|
||||||
|
"try_files $uri =404",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "/", custom_directives: [`return 301 https://${host}$request_uri`] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
listen_ports: [listen],
|
||||||
|
server_names: [host],
|
||||||
|
ssl_certificate: tls.ssl_certificate,
|
||||||
|
ssl_certificate_key: tls.ssl_certificate_key,
|
||||||
|
custom_directives: [
|
||||||
|
...(c.http2 === "listen" ? [] : ["http2 on"]),
|
||||||
|
...tls.custom,
|
||||||
|
'add_header Strict-Transport-Security "max-age=31536000" always',
|
||||||
|
"client_max_body_size 2m",
|
||||||
|
],
|
||||||
|
locations: [proxyLocation(c, "https")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Same proxy on a loopback HTTP port, for a smoke test with no certificate. */
|
||||||
|
export const httpVhostModel = (c: TargetContext, listen = 8080): NginxModel => ({
|
||||||
|
upstreams: [upstream(c)],
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
listen_ports: [`127.0.0.1:${listen}`],
|
||||||
|
server_names: ["_"],
|
||||||
|
custom_directives: ["client_max_body_size 2m"],
|
||||||
|
locations: [proxyLocation(c, "http")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Argv of `aphrody infra nginx render`, or undefined when no installed aphrody has it. */
|
||||||
|
export function nginxRenderer(): string[] | undefined {
|
||||||
|
const candidates: string[][] = [];
|
||||||
|
const infra = Bun.which("aphrody-infra");
|
||||||
|
if (infra) candidates.push([infra, "nginx", "render"]);
|
||||||
|
const aphrody = Bun.which("aphrody");
|
||||||
|
if (aphrody) candidates.push([aphrody, "infra", "nginx", "render"]);
|
||||||
|
return candidates.find(
|
||||||
|
(argv) =>
|
||||||
|
Bun.spawnSync([...argv, "--help"], { windowsHide: true, stdout: "ignore", stderr: "ignore" })
|
||||||
|
.exitCode === 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a model through `aphrody infra nginx render`. */
|
||||||
|
export async function renderNginx(
|
||||||
|
renderer: string[],
|
||||||
|
model: NginxModel,
|
||||||
|
comments: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
const proc = Bun.spawn([...renderer, ...comments.flatMap((line) => ["--comment", line]), "-"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdin: new TextEncoder().encode(JSON.stringify(model)),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
Bun.readableStreamToText(proc.stdout),
|
||||||
|
Bun.readableStreamToText(proc.stderr),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
if (code !== 0) throw new Error(`${renderer.join(" ")} failed (${code}): ${stderr.trim()}`);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeEntry = (c: TargetContext) => ({
|
||||||
|
host: hostOf(c),
|
||||||
|
service: c.name,
|
||||||
|
upstream: `127.0.0.1:${c.port}`,
|
||||||
|
probePath: "/healthz",
|
||||||
|
vhost: `nginx/${c.namespace}/${c.name}.conf`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nginx: Target = {
|
||||||
|
id: "nginx",
|
||||||
|
title: "nginx",
|
||||||
|
description:
|
||||||
|
"TLS vhost proxying to the Bun process and an HTTP-only variant, rendered by `aphrody infra nginx render`, and the routes.json entry",
|
||||||
|
async generate(c): Promise<GeneratedFile[]> {
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
file: `${c.name}.conf`,
|
||||||
|
model: vhostModel(c),
|
||||||
|
comment: `${c.name}: M3 app (UI and API from one Bun process on 127.0.0.1:${c.port}).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: `${c.name}.http.conf`,
|
||||||
|
model: httpVhostModel(c),
|
||||||
|
comment: `${c.name}: HTTP-only variant for local tests.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const files: GeneratedFile[] = variants.map((v) => ({
|
||||||
|
path: v.file.replace(/\.conf$/, ".nginx.json"),
|
||||||
|
content: `${JSON.stringify(v.model, null, 2)}\n`,
|
||||||
|
}));
|
||||||
|
const renderer = nginxRenderer();
|
||||||
|
const rendered = await Promise.all(
|
||||||
|
variants.map(async (v): Promise<GeneratedFile> => {
|
||||||
|
if (renderer) {
|
||||||
|
return {
|
||||||
|
path: v.file,
|
||||||
|
content: await renderNginx(renderer, v.model, [GENERATED_NOTE, v.comment]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const json = v.file.replace(/\.conf$/, ".nginx.json");
|
||||||
|
return {
|
||||||
|
path: `${v.file}.todo`,
|
||||||
|
content: `# ${GENERATED_NOTE}\n# No \`aphrody infra nginx\` on PATH: render this vhost with\n# aphrody infra nginx render ${json} > ${v.file}\n`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
files.push(...rendered);
|
||||||
|
files.push({
|
||||||
|
path: "routes.entry.json",
|
||||||
|
content: `${JSON.stringify(routeEntry(c), null, 2)}\n`,
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Desktop themes in the app's colours, through @aphrody/m3-os-themes: Windows 11 (accent, wallpaper, .theme),
|
||||||
|
// GNOME Shell, COSMIC and Chromium browsers, all derived from the seed of `m3.config.ts`. The installers are
|
||||||
|
// generated next to their files and never run: the README lists the command of each one.
|
||||||
|
import { GENERATED_NOTE } from "../util.ts";
|
||||||
|
import type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
|
||||||
|
const HOST = { windows: "win32", gnome: "linux", cosmic: "linux", chromium: "linux" } as const;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [themes, cli] = await Promise.all([
|
||||||
|
import("@aphrody/m3-os-themes"),
|
||||||
|
import("@aphrody/m3-os-themes/cli"),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
generate: themes.generate,
|
||||||
|
targets: themes.TARGETS,
|
||||||
|
installCommand: cli.installCommand,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: the os-themes target needs @aphrody/m3-os-themes (${(error as Error).message})`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `m3.config.ts` names are npm-like; theme names are `[A-Za-z0-9_-]+`. */
|
||||||
|
export const themeName = (name: string): string =>
|
||||||
|
name.replace(/^@/, "").replace(/[^A-Za-z0-9_-]+/g, "-");
|
||||||
|
|
||||||
|
export async function generateOsThemes(c: TargetContext): Promise<TargetFile[]> {
|
||||||
|
const { generate, targets, installCommand } = await load();
|
||||||
|
const name = themeName(c.name);
|
||||||
|
const seed = c.seed ?? "#6750a4";
|
||||||
|
const files = generate({
|
||||||
|
name,
|
||||||
|
seed,
|
||||||
|
mode: "auto",
|
||||||
|
...(c.variant ? { variant: c.variant as never } : {}),
|
||||||
|
});
|
||||||
|
const commands = targets.map((target) => {
|
||||||
|
const { cmd, reason } = installCommand(target, ".", "auto", false, HOST[target]);
|
||||||
|
return `- **${target}**: ${cmd.length > 0 ? `\`${cmd.join(" ").replaceAll("\\", "/")}\`` : reason}`;
|
||||||
|
});
|
||||||
|
const readme = `<!-- ${GENERATED_NOTE} -->
|
||||||
|
|
||||||
|
# ${c.name}: desktop themes
|
||||||
|
|
||||||
|
Generated by @aphrody/m3-os-themes from seed \`${seed}\`${c.variant ? ` (variant ${c.variant})` : ""}, light and dark,
|
||||||
|
active mode \`auto\`. Nothing is installed: run the installer of a desktop from this directory.
|
||||||
|
|
||||||
|
${commands.join("\n")}
|
||||||
|
|
||||||
|
Each installer takes \`--uninstall\` (\`-Uninstall\` on Windows). \`m3-os-themes install --target all --seed ${seed} --mode auto --apply\`
|
||||||
|
does the same in one step.
|
||||||
|
`;
|
||||||
|
return [
|
||||||
|
...files.map(({ path, content, executable }): TargetFile =>
|
||||||
|
executable ? { path, content, mode: 0o755 } : { path, content },
|
||||||
|
),
|
||||||
|
{ path: "README.md", content: readme },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const osThemes: Target = {
|
||||||
|
id: "os-themes",
|
||||||
|
title: "Desktop themes",
|
||||||
|
description:
|
||||||
|
"Windows 11, GNOME Shell, COSMIC and Chromium themes from the app seed (@aphrody/m3-os-themes), with their installers",
|
||||||
|
generate: generateOsThemes,
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// GitHub release workflow: on a `v*` tag, every runner installs the Bun of the aphrody-labs fork with its
|
||||||
|
// install.sh (the runtime m3-bun is developed with), packages the app for each target with `m3-bun package`
|
||||||
|
// and attaches the archives and their SHA-256 to the GitHub release. The release scripts of the Bun repository
|
||||||
|
// (packages/bun-release) publish Bun itself and are not reused here.
|
||||||
|
import { GENERATED_NOTE } from "../util.ts";
|
||||||
|
import type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
|
||||||
|
export const RELEASE_MATRIX = [
|
||||||
|
{ target: "linux-x64", os: "ubuntu-latest" },
|
||||||
|
{ target: "linux-x64-musl", os: "ubuntu-latest" },
|
||||||
|
{ target: "linux-arm64", os: "ubuntu-24.04-arm" },
|
||||||
|
{ target: "windows-x64", os: "ubuntu-latest" },
|
||||||
|
{ target: "darwin-arm64", os: "macos-latest" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** The release tag of the aphrody-labs Bun fork for `version` (`aphrody-v1.4.3-aphrody.2`), when it is known. */
|
||||||
|
export function forkTag(version: string): string | undefined {
|
||||||
|
if (version.includes("-aphrody.")) return `aphrody-v${version.replace(/^v/, "")}`;
|
||||||
|
const running = /^v?(\d+\.\d+\.\d+-aphrody\.\d+)/.exec(
|
||||||
|
(Bun as { version_with_sha?: string }).version_with_sha ?? "",
|
||||||
|
)?.[1];
|
||||||
|
return running?.startsWith(`${version}-`) ? `aphrody-v${running}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fork install.sh takes a fork tag, or `X.Y.Z` for the newest fork build of it. */
|
||||||
|
export const forkBunVersion = (version: string): string => forkTag(version) ?? version;
|
||||||
|
|
||||||
|
export const releaseWorkflow = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# Release ${c.name}: \`git tag v${c.version} && git push --tags\`. Copy to .github/workflows/release.yml.
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
concurrency:
|
||||||
|
group: release-\${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
${RELEASE_MATRIX.map((m) => ` - { target: ${m.target}, os: ${m.os} }`).join("\n")}
|
||||||
|
runs-on: \${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Bun (aphrody-labs fork)
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/aphrody-labs/bun/main/scripts/aphrody/install.sh | bash -s ${forkBunVersion(c.bunVersion)}
|
||||||
|
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: \${{ github.token }}
|
||||||
|
- run: bun --revision
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
- run: bun test
|
||||||
|
if: matrix.target == 'linux-x64'
|
||||||
|
- name: Package \${{ matrix.target }}
|
||||||
|
run: ${c.cli} package --target \${{ matrix.target }} --out release
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${c.name}-\${{ matrix.target }}
|
||||||
|
path: release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
publish:
|
||||||
|
needs: package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: release
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Checksums
|
||||||
|
working-directory: release
|
||||||
|
run: sha256sum $(ls | grep -v '\\.sha256$' | grep -v SHA256SUMS) > SHA256SUMS
|
||||||
|
- name: GitHub release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: gh release create "$GITHUB_REF_NAME" release/* --repo "$GITHUB_REPOSITORY" --title "${c.name} $GITHUB_REF_NAME" --generate-notes --verify-tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: \${{ github.token }}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const release: Target = {
|
||||||
|
id: "release",
|
||||||
|
title: "GitHub release",
|
||||||
|
description:
|
||||||
|
".github/workflows/release.yml: fork Bun via install.sh, m3-bun package for each target, archives and SHA256SUMS on the GitHub release",
|
||||||
|
generate(c): TargetFile[] {
|
||||||
|
return [{ path: ".github/workflows/release.yml", content: releaseWorkflow(c) }];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// systemd: a unit in the shape of the yolo host units (`tools/config/host/systemd/<ns>/*.service`: Type=simple, an
|
||||||
|
// unprivileged user, `WorkingDirectory=/home/<user>/apps/<ns>/current`, an `EnvironmentFile` under `/etc/<ns>`, restart on
|
||||||
|
// failure), with the sandbox written as the shared drop-ins of `tools/config/host/systemd/shared` (copied from the
|
||||||
|
// yolo checkout when it is there), a catalog entry in the `service-catalog.json` format and an env example.
|
||||||
|
// Nothing here installs anything: hosts change through Infra's catalog, plan first.
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
/** The shared drop-ins this unit uses, in application order, with the text used when yolo is not checked out. */
|
||||||
|
export const SHARED_DROPINS: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
[
|
||||||
|
"sandbox-base.conf",
|
||||||
|
"# Baseline sandbox: no privilege gain, private /tmp, read-only OS tree (add ReadWritePaths per unit).\n[Service]\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\n",
|
||||||
|
],
|
||||||
|
["sandbox-home-ro.conf", "# Home directories read-only.\n[Service]\nProtectHome=read-only\n"],
|
||||||
|
[
|
||||||
|
"sandbox-kernel.conf",
|
||||||
|
"# Kernel tunables, modules and cgroups read-only; no setuid/setgid; no personality changes.\n[Service]\nProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\nRestrictSUIDSGID=true\nLockPersonality=true\n",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"net-inet-only.conf",
|
||||||
|
"# Restrict sockets to unix, IPv4 and IPv6.\n[Service]\nRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"syscall-service.conf",
|
||||||
|
"# Allow the @system-service syscall set and deny obsolete/privileged calls.\n[Service]\nSystemCallFilter=@system-service\nSystemCallFilter=~@obsolete @privileged\n",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A shared drop-in from `<yolo>/tools/config/host/systemd/shared`, else the embedded text. */
|
||||||
|
export function sharedDropin(yoloRoot: string, file: string): string {
|
||||||
|
const path = join(yoloRoot, "tools/config/host/systemd/shared", file);
|
||||||
|
if (existsSync(path)) return readFileSync(path, "utf8");
|
||||||
|
return SHARED_DROPINS.find(([name]) => name === file)![1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unit = (c: TargetContext): string => `# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# ${GENERATED_NOTE}
|
||||||
|
[Unit]
|
||||||
|
Description=${c.name}: M3 app, UI and API from one Bun executable
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${c.user}
|
||||||
|
Group=${c.user}
|
||||||
|
WorkingDirectory=/home/${c.user}/apps/${c.namespace}/current
|
||||||
|
Environment=PORT=${c.port}
|
||||||
|
Environment=HOST=127.0.0.1
|
||||||
|
EnvironmentFile=-/etc/${c.namespace}/${c.name}.env
|
||||||
|
ExecStart=/home/${c.user}/apps/${c.namespace}/current/bin/${c.binary}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const catalogEntry = (c: TargetContext) => ({
|
||||||
|
id: c.name,
|
||||||
|
application: c.namespace,
|
||||||
|
kind: "bun-http",
|
||||||
|
runtime: "bun",
|
||||||
|
unit: `${c.name}.service`,
|
||||||
|
health: `http://127.0.0.1:${c.port}/healthz`,
|
||||||
|
port: c.port,
|
||||||
|
sourceRepo: c.namespace,
|
||||||
|
workingDirectory: `/home/${c.user}/apps/${c.namespace}/current`,
|
||||||
|
environmentFile: `/etc/${c.namespace}/${c.name}.env`,
|
||||||
|
publicProxy: "nginx",
|
||||||
|
public: Boolean(c.domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const systemd: Target = {
|
||||||
|
id: "systemd",
|
||||||
|
title: "systemd",
|
||||||
|
description:
|
||||||
|
"Service unit in the yolo host shape, shared sandbox drop-ins, service-catalog entry, env example",
|
||||||
|
generate(c): GeneratedFile[] {
|
||||||
|
const dropins: GeneratedFile[] = SHARED_DROPINS.map(([name], i) => ({
|
||||||
|
path: `${c.name}.service.d/${String(10 + i * 10).padStart(2, "0")}-${name}`,
|
||||||
|
content: sharedDropin(c.yoloRoot, name),
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{ path: `${c.name}.service`, content: unit(c) },
|
||||||
|
...dropins,
|
||||||
|
{
|
||||||
|
path: `${c.name}.env.example`,
|
||||||
|
content: `# ${GENERATED_NOTE}\n# Installed as /etc/${c.namespace}/${c.name}.env (mode 0640, owner root:${c.user}); never commit real values.\nPORT=${c.port}\nHOST=127.0.0.1\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "service-catalog.entry.json",
|
||||||
|
content: `${JSON.stringify(catalogEntry(c), null, 2)}\n`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Tauri 3 desktop app. `@aphrody/web-to-tauri` probes the app directory and plans a sidecar conversion: the
|
||||||
|
// executable of `m3-bun compile` runs next to the app on loopback and serves the UI and the API, the
|
||||||
|
// `aphrody-tauri-wrap` runtime plugin starts it, waits for /healthz and loads it. `generateProject` writes the
|
||||||
|
// project through the Tauri shell and the pins of `@aphrody/scaffold`; it runs in a scratch directory and the
|
||||||
|
// files come back as the target's output. Nothing is compiled: `bun run build:sidecar` then `bun run build`
|
||||||
|
// in the generated directory do that.
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import { GENERATED_NOTE } from "../util.ts";
|
||||||
|
import { appFromTarget } from "./lambda.ts";
|
||||||
|
import type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
|
||||||
|
const BINARY = /\.(?:png|ico|icns|webp)$/;
|
||||||
|
|
||||||
|
/** `scripts/build-sidecar.ts` of the project: `m3-bun compile` into `src-tauri/binaries/server-<host triple>`. */
|
||||||
|
export const sidecarScript = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
// Compiles ${c.name} (UI and API in one executable) into the sidecar Tauri bundles (\`bundle.externalBin\`).
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const app = process.env.SIDECAR_SOURCE ?? resolve(import.meta.dir, "..", ${JSON.stringify(appFromTarget(c))});
|
||||||
|
const host = /host: (\\S+)/.exec(Bun.spawnSync(["rustc", "-vV"], { windowsHide: true }).stdout.toString())?.[1];
|
||||||
|
if (!host) throw new Error("rustc -vV did not report a host triple");
|
||||||
|
const outfile = resolve("src-tauri/binaries", \`server-\${host}\${host.includes("windows") ? ".exe" : ""}\`);
|
||||||
|
const result = Bun.spawnSync([...${JSON.stringify(c.cli.split(" "))}, "compile", "--outfile", outfile], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: app,
|
||||||
|
stdio: ["inherit", "inherit", "inherit"],
|
||||||
|
});
|
||||||
|
process.exit(result.exitCode ?? 1);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Rebase the relative crate path `generateProject` wrote for the scratch directory onto the target directory. */
|
||||||
|
function rebaseCrate(cargo: string, scratchTauri: string, finalTauri: string | undefined): string {
|
||||||
|
return cargo.replace(
|
||||||
|
/^(aphrody-tauri-wrap = \{ path = ")([^"]+)(")/m,
|
||||||
|
(_, head: string, path: string, tail: string) => {
|
||||||
|
const crate = resolve(scratchTauri, path);
|
||||||
|
const next = finalTauri ? relative(finalTauri, crate) : crate;
|
||||||
|
return `${head}${next.replaceAll("\\", "/")}${tail}`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTauri(c: TargetContext): Promise<TargetFile[]> {
|
||||||
|
if (!c.appDir)
|
||||||
|
throw new Error("m3-bun: the tauri target reads the app directory (contextFor sets appDir)");
|
||||||
|
let w2t: typeof import("@aphrody/web-to-tauri");
|
||||||
|
let names: typeof import("@aphrody/scaffold/names");
|
||||||
|
try {
|
||||||
|
[w2t, names] = await Promise.all([
|
||||||
|
import("@aphrody/web-to-tauri"),
|
||||||
|
import("@aphrody/scaffold/names"),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: the tauri target needs @aphrody/web-to-tauri and @aphrody/scaffold (${(error as Error).message})`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const probe = await w2t.probeDirectory(c.appDir);
|
||||||
|
// The server entry of an m3 app is its config: `m3-bun compile` turns it into the sidecar executable.
|
||||||
|
const plan = w2t.planConversion(
|
||||||
|
{ ...probe, serverEntry: probe.serverEntry ?? "m3.config.ts" },
|
||||||
|
{
|
||||||
|
mode: "sidecar",
|
||||||
|
name: c.windows?.title ?? c.name,
|
||||||
|
identifier: names.tauriIdentifier(c.name),
|
||||||
|
port: c.port,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (plan.config.sidecar) plan.config.sidecar = { ...plan.config.sidecar, readyPath: "/healthz" };
|
||||||
|
|
||||||
|
const scratch = await mkdtemp(join(tmpdir(), "m3-bun-tauri-"));
|
||||||
|
try {
|
||||||
|
const offline: typeof fetch = Object.assign(
|
||||||
|
() => Promise.reject(new Error("m3-bun: the tauri target does not fetch")),
|
||||||
|
{ preconnect: () => {} },
|
||||||
|
);
|
||||||
|
const result = await w2t.generateProject(plan, scratch, { copyLock: false, fetch: offline });
|
||||||
|
const files: TargetFile[] = [];
|
||||||
|
for (const file of result.files) {
|
||||||
|
const path = relative(scratch, file).replaceAll("\\", "/");
|
||||||
|
const blob = Bun.file(file);
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Read back a bounded set of generated files in order.
|
||||||
|
let content: string | Uint8Array = BINARY.test(path) ? await blob.bytes() : await blob.text();
|
||||||
|
if (path === "src-tauri/Cargo.toml")
|
||||||
|
content = rebaseCrate(
|
||||||
|
content as string,
|
||||||
|
join(scratch, "src-tauri"),
|
||||||
|
c.targetDir && join(c.targetDir, "src-tauri"),
|
||||||
|
);
|
||||||
|
if (path === "scripts/build-sidecar.ts") content = sidecarScript(c);
|
||||||
|
if (path === "src-tauri/tauri.conf.json" || path === "package.json")
|
||||||
|
content = `${JSON.stringify({ ...JSON.parse(content as string), version: c.version }, null, 2)}\n`;
|
||||||
|
files.push({ path, content });
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
} finally {
|
||||||
|
await rm(scratch, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tauri: Target = {
|
||||||
|
id: "tauri",
|
||||||
|
title: "Tauri",
|
||||||
|
description:
|
||||||
|
"Tauri 3 project from @aphrody/web-to-tauri (sidecar plan) and the @aphrody/scaffold shell: the compiled app runs as the sidecar",
|
||||||
|
generate: generateTauri,
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import type { GeneratedFile } from "../util.ts";
|
||||||
|
|
||||||
|
/** Everything a deploy target may depend on. Derived from the app by `contextFor` (index.ts). */
|
||||||
|
export interface TargetContext {
|
||||||
|
/** Service and app name, a DNS-safe label. */
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
port: number;
|
||||||
|
/** Public host name, when known. */
|
||||||
|
domain?: string;
|
||||||
|
/** Container image reference. */
|
||||||
|
image: string;
|
||||||
|
/** Install namespace under `~/apps` (one home per host) and `/etc` (yolo host convention). */
|
||||||
|
namespace: string;
|
||||||
|
/** Unprivileged account of the systemd unit. */
|
||||||
|
user: string;
|
||||||
|
/** Fly.io primary region. */
|
||||||
|
region: string;
|
||||||
|
/** Name of the compiled Linux executable, in images and under `~/apps/<ns>/current/bin`. */
|
||||||
|
binary: string;
|
||||||
|
/** UI build directory, relative to the app. */
|
||||||
|
outdir: string;
|
||||||
|
/** API module relative to the app (`src/api.ts`), when the app has one. */
|
||||||
|
api?: string;
|
||||||
|
/** Bun version the app is built with. */
|
||||||
|
bunVersion: string;
|
||||||
|
/** The yolo checkout (host conventions are read from `tools/config/host`). */
|
||||||
|
yoloRoot: string;
|
||||||
|
/** How generated scripts invoke the CLI (`bunx --bun m3-bun`). */
|
||||||
|
cli: string;
|
||||||
|
/** TLS for nginx: Let's Encrypt paths (default) or explicit certificate files. */
|
||||||
|
tls: "letsencrypt" | { cert: string; key: string };
|
||||||
|
/** HTTP/2 in the nginx vhost: the `http2 on;` directive of nginx 1.25.1+ (the yolo convention, default) or `listen ... http2`. */
|
||||||
|
http2?: "directive" | "listen";
|
||||||
|
/** Windows executable metadata; `icon` is relative to the app (`.ico` is used as is, `.svg` is converted). */
|
||||||
|
windows?: { title?: string; publisher?: string; description?: string; icon?: string };
|
||||||
|
/** Absolute app directory. Targets that read the app (tauri, windows icon) need it. */
|
||||||
|
appDir?: string;
|
||||||
|
/** Seed colour of the app theme, `#rrggbb` (`theme.seed` of `m3.config.ts`). */
|
||||||
|
seed?: string;
|
||||||
|
/** Scheme variant of the app theme (`theme.variant`). */
|
||||||
|
variant?: string;
|
||||||
|
/** Material Symbol used as the application icon (`rocket_launch`); default: the Aphrody emblem. */
|
||||||
|
iconSymbol?: string;
|
||||||
|
/** Absolute directory this target is written to, set by `generateTargets`. */
|
||||||
|
targetDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generated file; binary payloads (icons, wallpapers) are bytes. */
|
||||||
|
export type TargetFile = Omit<GeneratedFile, "content"> & { content: string | Uint8Array };
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** One line: what is generated and what it is for. */
|
||||||
|
description: string;
|
||||||
|
generate(context: TargetContext): TargetFile[] | Promise<TargetFile[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Vercel: static output (the `m3-bun build` directory) plus the API as one function. The function reuses the
|
||||||
|
// app's Bun routes through `routesToFetch`, so the API is written once. `bunVersion` selects the Bun runtime.
|
||||||
|
// Copy the files into the project root; nothing is deployed from here.
|
||||||
|
import { GENERATED_NOTE, type GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
export const vercelJson = (c: TargetContext) => ({
|
||||||
|
$schema: "https://openapi.vercel.sh/vercel.json",
|
||||||
|
bunVersion: "1.x",
|
||||||
|
framework: null,
|
||||||
|
installCommand: "bun install --frozen-lockfile",
|
||||||
|
buildCommand: `${c.cli} build`,
|
||||||
|
outputDirectory: c.outdir,
|
||||||
|
rewrites: [
|
||||||
|
...(c.api ? [{ source: "/api/(.*)", destination: "/api" }] : []),
|
||||||
|
{ source: "/healthz", destination: "/api" },
|
||||||
|
{ source: "/((?!assets/|chunk-|api/).*)", destination: "/index.html" },
|
||||||
|
],
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/chunk-(.*)",
|
||||||
|
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
|
||||||
|
},
|
||||||
|
{ source: "/index.html", headers: [{ key: "Cache-Control", value: "no-cache" }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFunction = (c: TargetContext): string => `// ${GENERATED_NOTE}
|
||||||
|
// Vercel function: the app's Bun routes as one fetch handler. Place at <project>/api/index.ts.
|
||||||
|
import { normalizeApi, routesToFetch } from "@aphrody/m3-bun/api";
|
||||||
|
${c.api ? `import api from ${JSON.stringify(`../${c.api}`)};` : "const api = {};"}
|
||||||
|
|
||||||
|
const handler = routesToFetch(normalizeApi(api).routes);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const { pathname } = new URL(request.url);
|
||||||
|
if (pathname === "/healthz") return Response.json({ ok: true, name: ${JSON.stringify(c.name)}, version: ${JSON.stringify(c.version)} });
|
||||||
|
return (await handler(request)) ?? new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const vercel: Target = {
|
||||||
|
id: "vercel",
|
||||||
|
title: "Vercel",
|
||||||
|
description:
|
||||||
|
"vercel.json (static output, SPA rewrites, headers, Bun runtime) and an api/index.ts function over the app's routes",
|
||||||
|
generate(c): GeneratedFile[] {
|
||||||
|
return [
|
||||||
|
{ path: "vercel.json", content: `${JSON.stringify(vercelJson(c), null, 2)}\n` },
|
||||||
|
{ path: "api/index.ts", content: apiFunction(c) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// VS Code: tasks for every m3-bun command (the dev server is a background task with a problem matcher that
|
||||||
|
// knows when it is ready), debug configurations for the Bun debugger, and extension recommendations.
|
||||||
|
// settings.json configures the Bun extension (packages/bun-vscode of the Bun fork, `oven.bun-vscode`): its debugger,
|
||||||
|
// debug terminal and test explorer. Copy `.vscode/` into the project; nothing is installed.
|
||||||
|
import type { GeneratedFile } from "../util.ts";
|
||||||
|
import type { Target, TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
const task = (label: string, args: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
label,
|
||||||
|
type: "shell",
|
||||||
|
command: args,
|
||||||
|
group:
|
||||||
|
label === "m3-bun: build"
|
||||||
|
? { kind: "build", isDefault: true }
|
||||||
|
: label === "m3-bun: test"
|
||||||
|
? { kind: "test", isDefault: true }
|
||||||
|
: undefined,
|
||||||
|
problemMatcher: [],
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tasksJson = (c: TargetContext) => ({
|
||||||
|
version: "2.0.0",
|
||||||
|
tasks: [
|
||||||
|
task("m3-bun: dev", `${c.cli} dev`, {
|
||||||
|
isBackground: true,
|
||||||
|
presentation: { reveal: "always", panel: "dedicated" },
|
||||||
|
problemMatcher: {
|
||||||
|
owner: "m3-bun",
|
||||||
|
pattern: { regexp: "^$" },
|
||||||
|
background: {
|
||||||
|
activeOnStart: true,
|
||||||
|
beginsPattern: "^m3-bun dev",
|
||||||
|
endsPattern: "listening on http",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
task("m3-bun: build", `${c.cli} build`),
|
||||||
|
task("m3-bun: compile", `${c.cli} compile`),
|
||||||
|
task("m3-bun: compile (windows-x64)", `${c.cli} compile --target windows-x64`),
|
||||||
|
task("m3-bun: compile (linux-x64-musl)", `${c.cli} compile --target linux-x64-musl`),
|
||||||
|
task("m3-bun: serve", `${c.cli} serve`, { isBackground: true, problemMatcher: [] }),
|
||||||
|
task("m3-bun: deploy (generate all targets)", `${c.cli} deploy all`),
|
||||||
|
task("m3-bun: package", `${c.cli} package`),
|
||||||
|
task("m3-bun: smoke (compiled executable)", `${c.cli} smoke`),
|
||||||
|
task("m3-bun: test", "bun test"),
|
||||||
|
task("m3-bun: lint", "bun x oxlint ."),
|
||||||
|
task("m3-bun: typecheck", "bun x tsc --noEmit -p tsconfig.json"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const launchJson = (c: TargetContext) => ({
|
||||||
|
version: "0.2.0",
|
||||||
|
configurations: [
|
||||||
|
{
|
||||||
|
type: "bun",
|
||||||
|
request: "launch",
|
||||||
|
name: "m3-bun: debug dev server",
|
||||||
|
program: "${workspaceFolder}/node_modules/@aphrody/m3-bun/bin/m3-bun.ts",
|
||||||
|
args: ["dev"],
|
||||||
|
cwd: "${workspaceFolder}",
|
||||||
|
env: { PORT: String(c.port) },
|
||||||
|
stopOnEntry: false,
|
||||||
|
watchMode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bun",
|
||||||
|
request: "launch",
|
||||||
|
name: "m3-bun: debug production server",
|
||||||
|
program: "${workspaceFolder}/node_modules/@aphrody/m3-bun/bin/m3-bun.ts",
|
||||||
|
args: ["serve"],
|
||||||
|
cwd: "${workspaceFolder}",
|
||||||
|
env: { PORT: String(c.port) },
|
||||||
|
stopOnEntry: false,
|
||||||
|
watchMode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bun",
|
||||||
|
request: "launch",
|
||||||
|
name: "bun test: current file",
|
||||||
|
program: "${file}",
|
||||||
|
args: [],
|
||||||
|
cwd: "${workspaceFolder}",
|
||||||
|
stopOnEntry: false,
|
||||||
|
watchMode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bun",
|
||||||
|
request: "attach",
|
||||||
|
name: "bun: attach",
|
||||||
|
url: "ws://localhost:6499/",
|
||||||
|
stopOnEntry: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Settings read by the Bun extension; keys and types follow its `contributes.configuration`. */
|
||||||
|
export const settingsJson = (c: TargetContext) => ({
|
||||||
|
"bun.runtime": "bun",
|
||||||
|
"bun.debugTerminal.enabled": true,
|
||||||
|
"bun.debugTerminal.stopOnEntry": false,
|
||||||
|
"bun.diagnosticsSocket.enabled": true,
|
||||||
|
"bun.test.enable": true,
|
||||||
|
"bun.test.filePattern": "**/*{.test.,.spec.}{js,ts,tsx,jsx,mts,cts,mjs}",
|
||||||
|
"bun.test.customScript": "bun test",
|
||||||
|
"bun.test.customFlag": "",
|
||||||
|
"files.exclude": { [c.outdir]: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const vscode: Target = {
|
||||||
|
id: "vscode",
|
||||||
|
title: "VS Code",
|
||||||
|
description:
|
||||||
|
".vscode/tasks.json (dev, build, compile, serve, deploy, package, smoke, test), launch.json (Bun debugger), settings.json (Bun extension), extensions.json",
|
||||||
|
generate(c): GeneratedFile[] {
|
||||||
|
return [
|
||||||
|
{ path: ".vscode/tasks.json", content: `${JSON.stringify(tasksJson(c), null, 2)}\n` },
|
||||||
|
{ path: ".vscode/launch.json", content: `${JSON.stringify(launchJson(c), null, 2)}\n` },
|
||||||
|
{
|
||||||
|
path: ".vscode/settings.json",
|
||||||
|
content: `${JSON.stringify(settingsJson(c), null, 2)}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ".vscode/extensions.json",
|
||||||
|
content: `${JSON.stringify({ recommendations: ["oven.bun-vscode", "oxc.oxc-vscode"] }, null, 2)}\n`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
export * from "@aphrody/web-source/integrations/m3/packages/m3-bun/src/targets/webos.ts";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Read-only state of a Windows install made by the windows target: shortcuts, the Apps & features entry, the
|
||||||
|
// WinSW service and the installed executable, read through the `bun:windows` module of the Bun fork (known folders,
|
||||||
|
// registry, Service Control Manager; its Win32 families ship as @aphrody/bun-windows-*). Nothing is written.
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { TargetContext } from "./types.ts";
|
||||||
|
|
||||||
|
/** The members of `bun:windows` read here (the module exists only in the aphrody-labs fork of Bun). */
|
||||||
|
interface BunWindows {
|
||||||
|
isSupported: boolean;
|
||||||
|
isElevated(): boolean;
|
||||||
|
version(): { major: number; minor: number; build: number; productName?: string };
|
||||||
|
knownFolder(name: string): string;
|
||||||
|
registry: { get(path: string, name?: string): { type: string; value: unknown } | null };
|
||||||
|
services: { get(name: string): { state: string; startType: string; binaryPath: string } | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowsInspection {
|
||||||
|
windows: string;
|
||||||
|
elevated: boolean;
|
||||||
|
executable: { path: string; exists: boolean };
|
||||||
|
shortcuts: {
|
||||||
|
startMenu: { path: string; exists: boolean };
|
||||||
|
desktop: { path: string; exists: boolean };
|
||||||
|
};
|
||||||
|
/** `HKCU\...\Uninstall\<name>` (Apps & features), or null when not registered. */
|
||||||
|
uninstall: {
|
||||||
|
key: string;
|
||||||
|
displayName?: string;
|
||||||
|
displayVersion?: string;
|
||||||
|
installLocation?: string;
|
||||||
|
} | null;
|
||||||
|
/** The WinSW service, or null when the app is not a service. */
|
||||||
|
service: { state: string; startType: string; binaryPath: string } | null;
|
||||||
|
/** Installed version equals the context version. */
|
||||||
|
upToDate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uninstallKey = (c: Pick<TargetContext, "name">): string =>
|
||||||
|
`HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${c.name}`;
|
||||||
|
|
||||||
|
export const shortcutName = (c: Pick<TargetContext, "name" | "windows">): string =>
|
||||||
|
`${c.windows?.title ?? c.name}.lnk`;
|
||||||
|
|
||||||
|
async function bunWindows(): Promise<BunWindows> {
|
||||||
|
const specifier = "bun:windows";
|
||||||
|
let windows: BunWindows;
|
||||||
|
try {
|
||||||
|
windows = ((await import(specifier)) as { default: BunWindows }).default;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: inspectWindows needs the bun:windows module of the aphrody-labs Bun fork (${(error as Error).message})`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!windows.isSupported) throw new Error("m3-bun: inspectWindows runs on Windows only");
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the windows target's scripts put things on this machine, and what is there now. */
|
||||||
|
export async function inspectWindows(
|
||||||
|
c: Pick<TargetContext, "name" | "version" | "windows">,
|
||||||
|
installDir?: string,
|
||||||
|
): Promise<WindowsInspection> {
|
||||||
|
const windows = await bunWindows();
|
||||||
|
const text = (path: string, name: string) => {
|
||||||
|
const value = windows.registry.get(path, name)?.value;
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
};
|
||||||
|
const key = uninstallKey(c);
|
||||||
|
const displayName = text(key, "DisplayName");
|
||||||
|
const displayVersion = text(key, "DisplayVersion");
|
||||||
|
const installLocation = text(key, "InstallLocation");
|
||||||
|
const dir = installDir ?? installLocation ?? join(windows.knownFolder("ProgramFiles"), c.name);
|
||||||
|
const exe = join(dir, `${c.name}.exe`);
|
||||||
|
const lnk = shortcutName(c);
|
||||||
|
const startMenu = join(windows.knownFolder("StartMenu"), "Programs", lnk);
|
||||||
|
const desktop = join(windows.knownFolder("Desktop"), lnk);
|
||||||
|
const service = windows.services.get(c.name);
|
||||||
|
const version = windows.version();
|
||||||
|
return {
|
||||||
|
windows: version.productName ?? `${version.major}.${version.minor}.${version.build}`,
|
||||||
|
elevated: windows.isElevated(),
|
||||||
|
executable: { path: exe, exists: existsSync(exe) },
|
||||||
|
shortcuts: {
|
||||||
|
startMenu: { path: startMenu, exists: existsSync(startMenu) },
|
||||||
|
desktop: { path: desktop, exists: existsSync(desktop) },
|
||||||
|
},
|
||||||
|
uninstall:
|
||||||
|
displayName === undefined && displayVersion === undefined
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
key,
|
||||||
|
...(displayName !== undefined ? { displayName } : {}),
|
||||||
|
...(displayVersion !== undefined ? { displayVersion } : {}),
|
||||||
|
...(installLocation !== undefined ? { installLocation } : {}),
|
||||||
|
},
|
||||||
|
service: service
|
||||||
|
? { state: service.state, startType: service.startType, binaryPath: service.binaryPath }
|
||||||
|
: null,
|
||||||
|
upToDate: displayVersion === c.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Windows: build script for the compiled `.exe` (cross-compiled from any OS), and service scripts. A Bun
|
||||||
|
// executable does not speak the Windows service control protocol, so `sc.exe create` on it fails with error 1053.
|
||||||
|
// The scripts therefore use WinSW (https://github.com/winsw/winsw, drop `WinSW-x64.exe` next to them) when it is
|
||||||
|
// there, and a scheduled task that starts at boot as the fallback.
|
||||||
|
// The desktop side installs per user without elevation: Start menu and desktop shortcuts and an Apps & features
|
||||||
|
// entry under HKCU. The application icon is an SVG (a Material Symbol from @aphrody/material-design-icons, the
|
||||||
|
// app's own, or the Aphrody emblem of @aphrody/identity) that build-windows.ts turns into icon.ico with
|
||||||
|
// m3-svg-to-ico before compiling; windows-check.ts reads the result back through `bun:windows` (inspectWindows).
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, extname, join, resolve } from "node:path";
|
||||||
|
import { GENERATED_NOTE } from "../util.ts";
|
||||||
|
import { appFromTarget } from "./lambda.ts";
|
||||||
|
import type { Target, TargetContext, TargetFile } from "./types.ts";
|
||||||
|
import { shortcutName, uninstallKey } from "./windows-inspect.ts";
|
||||||
|
|
||||||
|
export const buildScript = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# Build ${c.name}.exe (Windows x64) with its icon and metadata through build-windows.ts; Bun cross-compiles from
|
||||||
|
# any OS but applies the icon only on a Windows host.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
& bun (Join-Path $PSScriptRoot 'build-windows.ts') @args
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'build-windows.ts failed' }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Bun script: icon.svg to icons/icon.ico (m3-svg-to-ico), then compileApp for windows-x64 with the metadata. */
|
||||||
|
export const buildTs = (c: TargetContext): string => `#!/usr/bin/env bun
|
||||||
|
// ${GENERATED_NOTE}
|
||||||
|
// ${c.name}.exe: icons/icon.ico from icon.svg (m3-svg-to-ico, Tauri CLI), then \`compile --target windows-x64\`
|
||||||
|
// with the icon, title and publisher, and out/SHA256SUMS.
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { compileApp, loadApp } from "@aphrody/m3-bun";
|
||||||
|
import { windowsIcon } from "@aphrody/m3-bun/targets";
|
||||||
|
|
||||||
|
const here = import.meta.dir;
|
||||||
|
const ico = existsSync(join(here, "icons", "icon.ico")) ? join(here, "icons", "icon.ico") : windowsIcon(join(here, "icon.svg"), join(here, "icons"));
|
||||||
|
const app = await loadApp(join(here, ${JSON.stringify(appFromTarget(c))}));
|
||||||
|
app.bun.compile = {
|
||||||
|
...app.bun.compile,
|
||||||
|
windows: {
|
||||||
|
title: ${JSON.stringify(c.windows?.title ?? c.name)},
|
||||||
|
publisher: ${JSON.stringify(c.windows?.publisher ?? c.name)},
|
||||||
|
description: ${JSON.stringify(c.windows?.description ?? `${c.name}: M3 app, UI and API from one executable`)},
|
||||||
|
version: ${JSON.stringify(c.version)},
|
||||||
|
...app.bun.compile?.windows,
|
||||||
|
icon: ico,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const report = await compileApp({ app, target: "windows-x64", outfile: join(here, "out", ${JSON.stringify(`${c.name}.exe`)}) });
|
||||||
|
for (const warning of report.warnings) console.warn(warning);
|
||||||
|
const hash = new Bun.CryptoHasher("sha256").update(await Bun.file(report.outfile).bytes()).digest("hex");
|
||||||
|
await Bun.write(join(here, "out", "SHA256SUMS"), \`\${hash} ${c.name}.exe\\n\`);
|
||||||
|
console.log(\`built \${report.outfile} (\${hash})\`);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ps = (value: string): string => value.replaceAll("'", "''");
|
||||||
|
const registryPath = (c: TargetContext): string =>
|
||||||
|
`Registry::${uninstallKey(c).replace(/^HKCU/, "HKEY_CURRENT_USER")}`;
|
||||||
|
|
||||||
|
/** Per-user desktop install: copy the .exe, Start menu and desktop shortcuts, Apps & features entry (HKCU). */
|
||||||
|
export const installAppScript = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# Installs ${c.name} for the current user, no elevation: the executable under %LOCALAPPDATA%\\Programs, Start menu
|
||||||
|
# and desktop shortcuts, and an Apps & features entry. Nothing runs until you do; bun windows-check.ts verifies it.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\\${c.name}'),
|
||||||
|
[switch]$NoDesktopShortcut
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$exe = Join-Path $PSScriptRoot 'out\\${c.name}.exe'
|
||||||
|
if (-not (Test-Path $exe)) { throw "missing $exe (run build-windows.ps1)" }
|
||||||
|
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||||
|
Copy-Item $exe $InstallDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot 'uninstall-app.ps1') $InstallDir -Force
|
||||||
|
$target = Join-Path $InstallDir '${c.name}.exe'
|
||||||
|
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
$folders = @([Environment]::GetFolderPath('Programs'))
|
||||||
|
if (-not $NoDesktopShortcut) { $folders += [Environment]::GetFolderPath('Desktop') }
|
||||||
|
foreach ($folder in $folders) {
|
||||||
|
$link = $shell.CreateShortcut((Join-Path $folder '${ps(shortcutName(c))}'))
|
||||||
|
$link.TargetPath = $target
|
||||||
|
$link.WorkingDirectory = $InstallDir
|
||||||
|
$link.IconLocation = "$target,0"
|
||||||
|
$link.Description = '${ps(c.windows?.description ?? c.name)}'
|
||||||
|
$link.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = '${registryPath(c)}'
|
||||||
|
New-Item -Path $key -Force | Out-Null
|
||||||
|
$values = [ordered]@{
|
||||||
|
DisplayName = '${ps(c.windows?.title ?? c.name)}'
|
||||||
|
DisplayVersion = '${c.version}'
|
||||||
|
Publisher = '${ps(c.windows?.publisher ?? c.name)}'
|
||||||
|
DisplayIcon = "$target,0"
|
||||||
|
InstallLocation = $InstallDir
|
||||||
|
UninstallString = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \`"$(Join-Path $InstallDir 'uninstall-app.ps1')\`""
|
||||||
|
}
|
||||||
|
foreach ($name in $values.Keys) { New-ItemProperty -Path $key -Name $name -Value $values[$name] -PropertyType String -Force | Out-Null }
|
||||||
|
New-ItemProperty -Path $key -Name NoModify -Value 1 -PropertyType DWord -Force | Out-Null
|
||||||
|
New-ItemProperty -Path $key -Name NoRepair -Value 1 -PropertyType DWord -Force | Out-Null
|
||||||
|
Write-Host "installed $target"
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const uninstallAppScript = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# Removes the per-user install of ${c.name}: shortcuts, Apps & features entry and install folder.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$key = '${registryPath(c)}'
|
||||||
|
$dir = if (Test-Path $key) { (Get-ItemProperty $key).InstallLocation } else { $null }
|
||||||
|
foreach ($folder in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) {
|
||||||
|
Remove-Item (Join-Path $folder '${ps(shortcutName(c))}') -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
if (Test-Path $key) { Remove-Item $key -Recurse -Force }
|
||||||
|
if ($dir -and (Test-Path $dir)) { Remove-Item $dir -Recurse -Force }
|
||||||
|
Write-Host 'removed ${c.name}'
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const checkScript = (c: TargetContext): string => `#!/usr/bin/env bun
|
||||||
|
// ${GENERATED_NOTE}
|
||||||
|
// Read-only check of the Windows install of ${c.name} through bun:windows (Bun fork): executable, shortcuts,
|
||||||
|
// Apps & features entry, service. Exit status 1 when the executable is missing or not version ${c.version}.
|
||||||
|
import { inspectWindows } from "@aphrody/m3-bun/targets";
|
||||||
|
|
||||||
|
const report = await inspectWindows(${JSON.stringify({ name: c.name, version: c.version, ...(c.windows ? { windows: c.windows } : {}) })});
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
process.exit(report.executable.exists && report.upToDate ? 0 : 1);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** A PNG inside a square SVG, the input m3-svg-to-ico takes. */
|
||||||
|
export const pngSvg = (png: Uint8Array, size: number): string =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><image width="${size}" height="${size}" xlink:href="data:image/png;base64,${Buffer.from(png).toString("base64")}"/></svg>\n`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application icon: `windows.icon` of the config (`.ico` as is, `.svg`), a Material Symbol named by
|
||||||
|
* `iconSymbol` in the seed colour (the @aphrody/material-design-icons catalogue, else the m3-bun icon loader),
|
||||||
|
* or the Aphrody emblem of @aphrody/identity.
|
||||||
|
*/
|
||||||
|
export async function iconFile(c: TargetContext): Promise<TargetFile> {
|
||||||
|
const own = c.windows?.icon;
|
||||||
|
if (own) {
|
||||||
|
if (!c.appDir) throw new Error("m3-bun: windows.icon needs the app directory");
|
||||||
|
const path = resolve(c.appDir, own);
|
||||||
|
if (!existsSync(path)) throw new Error(`m3-bun: windows.icon ${own} does not exist`);
|
||||||
|
const ext = extname(path).toLowerCase();
|
||||||
|
if (ext === ".ico")
|
||||||
|
return { path: "icons/icon.ico", content: new Uint8Array(readFileSync(path)) };
|
||||||
|
if (ext !== ".svg") throw new Error(`m3-bun: windows.icon must be .ico or .svg, got ${own}`);
|
||||||
|
return { path: "icon.svg", content: readFileSync(path, "utf8") };
|
||||||
|
}
|
||||||
|
if (c.iconSymbol) {
|
||||||
|
const symbol = c.iconSymbol;
|
||||||
|
let symbols: typeof import("@aphrody/material-design-icons");
|
||||||
|
try {
|
||||||
|
symbols = await import("@aphrody/material-design-icons");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: the windows icon ${symbol} needs @aphrody/material-design-icons (${(error as Error).message})`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const svg = symbols.hasSymbol(symbol)
|
||||||
|
? symbols.readSymbolSvg(symbol)
|
||||||
|
: await import("../icons.ts").then(({ loadIcon, parseIconSpecifier }) =>
|
||||||
|
loadIcon(parseIconSpecifier(symbol)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
path: "icon.svg",
|
||||||
|
content: svg.replace(/<svg /, `<svg fill="${c.seed ?? "#6750a4"}" `),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let emblem: string;
|
||||||
|
try {
|
||||||
|
emblem = join(
|
||||||
|
dirname(Bun.resolveSync("@aphrody/identity", import.meta.dir)),
|
||||||
|
"../assets/icon-192.png",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: the default windows icon needs @aphrody/identity (${(error as Error).message})`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { path: "icon.svg", content: pngSvg(new Uint8Array(readFileSync(emblem)), 192) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `m3-svg-to-ico <svg> --out <dir>` (@aphrody/material-design-icons, through the Tauri CLI); returns `<dir>/icon.ico`. */
|
||||||
|
export function windowsIcon(svg: string, outDir: string): string {
|
||||||
|
let cli: string;
|
||||||
|
try {
|
||||||
|
cli = join(
|
||||||
|
dirname(Bun.resolveSync("@aphrody/material-design-icons", import.meta.dir)),
|
||||||
|
"../scripts/svg-to-ico.ts",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: windowsIcon needs @aphrody/material-design-icons (${(error as Error).message})`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const run = Bun.spawnSync([process.execPath, cli, svg, "--out", outDir], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: dirname(svg),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const ico = join(outDir, "icon.ico");
|
||||||
|
if (run.exitCode !== 0 || !existsSync(ico))
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: m3-svg-to-ico ${svg} failed (${run.exitCode}): ${run.stderr.toString().trim() || run.stdout.toString().trim()}`,
|
||||||
|
);
|
||||||
|
return ico;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const installScript = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
# Installs ${c.name} as a Windows service through WinSW, or as a boot-time scheduled task when WinSW is absent.
|
||||||
|
# Run from an elevated PowerShell next to ${c.name}.exe (and WinSW-x64.exe, if you have it). Nothing runs until you do.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = (Join-Path $env:ProgramFiles '${c.name}'),
|
||||||
|
[int]$Port = ${c.port},
|
||||||
|
[switch]$OpenFirewall
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run this script as Administrator' }
|
||||||
|
|
||||||
|
$exe = Join-Path $PSScriptRoot '${c.name}.exe'
|
||||||
|
if (-not (Test-Path $exe)) { throw "missing $exe (run build-windows.ps1)" }
|
||||||
|
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||||
|
Copy-Item $exe $InstallDir -Force
|
||||||
|
|
||||||
|
$winsw = Join-Path $PSScriptRoot 'WinSW-x64.exe'
|
||||||
|
if (Test-Path $winsw) {
|
||||||
|
$svc = Join-Path $InstallDir '${c.name}-service.exe'
|
||||||
|
Copy-Item $winsw $svc -Force
|
||||||
|
(Get-Content (Join-Path $PSScriptRoot '${c.name}-service.xml') -Raw).Replace('__PORT__', "$Port") | Set-Content -Encoding utf8 (Join-Path $InstallDir '${c.name}-service.xml')
|
||||||
|
& $svc install
|
||||||
|
& $svc start
|
||||||
|
Write-Host 'service installed with WinSW'
|
||||||
|
} else {
|
||||||
|
$action = New-ScheduledTaskAction -Execute (Join-Path $InstallDir '${c.name}.exe') -WorkingDirectory $InstallDir
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -Hidden -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||||
|
Register-ScheduledTask -TaskName '${c.name}' -Action $action -Trigger $trigger -Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
|
||||||
|
[Environment]::SetEnvironmentVariable('PORT', "$Port", 'Machine')
|
||||||
|
Start-ScheduledTask -TaskName '${c.name}'
|
||||||
|
Write-Host 'WinSW not found: registered a boot-time scheduled task instead'
|
||||||
|
}
|
||||||
|
if ($OpenFirewall) {
|
||||||
|
New-NetFirewallRule -DisplayName '${c.name}' -Direction Inbound -Protocol TCP -LocalPort $Port -Action Allow | Out-Null
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const uninstallScript = (c: TargetContext): string => `# ${GENERATED_NOTE}
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string]$InstallDir = (Join-Path $env:ProgramFiles '${c.name}'))
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$svc = Join-Path $InstallDir '${c.name}-service.exe'
|
||||||
|
if (Test-Path $svc) { & $svc stop; & $svc uninstall }
|
||||||
|
if (Get-ScheduledTask -TaskName '${c.name}' -ErrorAction SilentlyContinue) { Unregister-ScheduledTask -TaskName '${c.name}' -Confirm:$false }
|
||||||
|
Get-NetFirewallRule -DisplayName '${c.name}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule
|
||||||
|
Write-Host 'removed; ${c.name}.exe and its folder are left in place'
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const winswXml = (c: TargetContext): string => `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- ${GENERATED_NOTE} -->
|
||||||
|
<service>
|
||||||
|
<id>${c.name}</id>
|
||||||
|
<name>${c.windows?.title ?? c.name}</name>
|
||||||
|
<description>${c.windows?.description ?? `${c.name}: M3 app, UI and API from one executable`}</description>
|
||||||
|
<executable>%BASE%\\${c.name}.exe</executable>
|
||||||
|
<env name="PORT" value="__PORT__" />
|
||||||
|
<env name="HOST" value="0.0.0.0" />
|
||||||
|
<log mode="roll-by-size"><sizeThreshold>10240</sizeThreshold><keepFiles>5</keepFiles></log>
|
||||||
|
<onfailure action="restart" delay="5 sec" />
|
||||||
|
<onfailure action="restart" delay="30 sec" />
|
||||||
|
<startmode>Automatic</startmode>
|
||||||
|
</service>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const windows: Target = {
|
||||||
|
id: "windows",
|
||||||
|
title: "Windows",
|
||||||
|
description:
|
||||||
|
"build-windows.ps1/.ts (.exe with icon.ico from m3-svg-to-ico), per-user install (shortcuts, Apps & features), WinSW service or boot task, windows-check.ts (bun:windows)",
|
||||||
|
async generate(c): Promise<TargetFile[]> {
|
||||||
|
return [
|
||||||
|
await iconFile(c),
|
||||||
|
{ path: "build-windows.ps1", content: buildScript(c) },
|
||||||
|
{ path: "build-windows.ts", content: buildTs(c), mode: 0o755 },
|
||||||
|
{ path: "install-app.ps1", content: installAppScript(c) },
|
||||||
|
{ path: "uninstall-app.ps1", content: uninstallAppScript(c) },
|
||||||
|
{ path: "windows-check.ts", content: checkScript(c), mode: 0o755 },
|
||||||
|
{ path: "install-service.ps1", content: installScript(c) },
|
||||||
|
{ path: "uninstall-service.ps1", content: uninstallScript(c) },
|
||||||
|
{ path: `${c.name}-service.xml`, content: winswXml(c) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The `m3:*` sheet generators of m3-bun. The canonical one is the Rust token exporter: `m3Css` of `@aphrody/bun`,
|
||||||
|
// reached through the native bridge of `@aphrody/m3` (`defaultM3Css`). The Material Color Utilities port of
|
||||||
|
// `@aphrody/m3-tokens` is used only when a caller asks for it: `mcuM3Css`, `m3CssFor("mcu")`, or `tsM3Css` on a host
|
||||||
|
// without the native library. `tailwind` and `spaceui` are Rust-only sheets.
|
||||||
|
import type { M3CssConfig, M3CssFn } from "@aphrody/m3";
|
||||||
|
import type { NativeThemeExport } from "@aphrody/m3-tokens/native-theme";
|
||||||
|
import { cssFromSeed, schemeFromSeed, type SchemeVariant } from "@aphrody/m3-tokens/dynamic-color";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const VARIANTS: Record<string, SchemeVariant> = {
|
||||||
|
"tonal-spot": "tonalSpot",
|
||||||
|
tonalspot: "tonalSpot",
|
||||||
|
content: "content",
|
||||||
|
fidelity: "fidelity",
|
||||||
|
expressive: "expressive",
|
||||||
|
vibrant: "vibrant",
|
||||||
|
neutral: "neutral",
|
||||||
|
monochrome: "monochrome",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `tonal-spot` and `tonalSpot` both name the default scheme variant. */
|
||||||
|
export function schemeVariant(name: string | undefined): SchemeVariant {
|
||||||
|
if (!name) return "tonalSpot";
|
||||||
|
const variant = VARIANTS[name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()];
|
||||||
|
if (!variant) throw new Error(`m3-bun: unknown scheme variant ${name} (${Object.keys(VARIANTS).join(", ")})`);
|
||||||
|
return variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticSheets = (): string =>
|
||||||
|
["@aphrody/m3-tokens/m3-tokens.css", "@aphrody/m3-tokens/expressive.css"]
|
||||||
|
.map(specifier => readFileSync(Bun.resolveSync(specifier, import.meta.dir), "utf8"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
/** The Material Color Utilities generator of `@aphrody/m3-tokens`: no native library, `tokens` and `scheme` only. */
|
||||||
|
export const mcuM3Css: M3CssFn = (config: M3CssConfig): string => {
|
||||||
|
const seed = config.seed ?? "#6750a4";
|
||||||
|
const options = {
|
||||||
|
variant: schemeVariant(config.variant),
|
||||||
|
...(config.contrast !== undefined ? { contrastLevel: config.contrast } : {}),
|
||||||
|
};
|
||||||
|
switch (config.sheet) {
|
||||||
|
case "tokens":
|
||||||
|
return `${staticSheets()}\n${cssFromSeed(seed, options)}`;
|
||||||
|
case "scheme": {
|
||||||
|
const roles = schemeFromSeed(seed, { ...options, dark: config.dark ?? false });
|
||||||
|
return `:root {\n${Object.entries(roles)
|
||||||
|
.map(([name, value]) => ` ${name}: ${value};`)
|
||||||
|
.join("\n")}\n}\n`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun: the ${config.sheet} sheet needs the Rust m3Css (@aphrody/bun); import @aphrody/m3-tailwind/preset.css or pass \`css\` to the plugin`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AphrodyBun {
|
||||||
|
m3Css?: M3CssFn;
|
||||||
|
isAphrodyFfiAvailable?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface M3NativeBridge {
|
||||||
|
defaultM3Css(): Promise<M3CssFn>;
|
||||||
|
loadAphrodyBun(): Promise<AphrodyBun>;
|
||||||
|
findNativeTheme(start: string): string | undefined;
|
||||||
|
readNativeTheme(path: string): { theme: NativeThemeExport; css: string };
|
||||||
|
nativeThemeMatches(config: M3CssConfig, theme: NativeThemeExport): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `@aphrody/m3` keeps its native bridge out of its export map: its `package.json` locates the module, and
|
||||||
|
// `@aphrody/bun` resolves from there because it is a dependency of `@aphrody/m3`, not of this package.
|
||||||
|
const bridgeFile = (): string =>
|
||||||
|
join(dirname(Bun.resolveSync("@aphrody/m3/package.json", import.meta.dir)), "src", "native.ts");
|
||||||
|
|
||||||
|
let bridge: Promise<M3NativeBridge> | undefined;
|
||||||
|
const nativeBridge = (): Promise<M3NativeBridge> => (bridge ??= import(bridgeFile()) as Promise<M3NativeBridge>);
|
||||||
|
|
||||||
|
const usable = (mod: AphrodyBun): M3CssFn | undefined =>
|
||||||
|
typeof mod.m3Css === "function" && mod.isAphrodyFfiAvailable?.() !== false ? mod.m3Css : undefined;
|
||||||
|
|
||||||
|
/** The Rust `m3Css`, or `undefined` when `@aphrody/bun` or its native library cannot be loaded on this host. */
|
||||||
|
export async function nativeM3Css(): Promise<M3CssFn | undefined> {
|
||||||
|
try {
|
||||||
|
return usable(await (await nativeBridge()).loadAphrodyBun());
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type M3CssEngine = "native" | "mcu";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sheet generator of an engine. `native` (default) serves the Rust artifacts: with `start`, the ThemeExport above
|
||||||
|
* it for the theme it was exported for (`tokens` from its CSS, `scheme` from its roles), then `defaultM3Css` of
|
||||||
|
* `@aphrody/m3` (the Rust `m3Css`, or the committed sheets of `@aphrody/m3-theme` for the default theme; any other
|
||||||
|
* request then fails and names the missing library). `mcu` is {@link mcuM3Css}.
|
||||||
|
*/
|
||||||
|
export async function m3CssFor(engine: M3CssEngine = "native", start?: string): Promise<M3CssFn> {
|
||||||
|
if (engine === "mcu") return mcuM3Css;
|
||||||
|
if (engine !== "native") throw new Error(`m3-bun: unknown css engine ${String(engine)} (native, mcu)`);
|
||||||
|
const native = await nativeBridge();
|
||||||
|
const generate = await native.defaultM3Css();
|
||||||
|
const artifact = start ? await themeArtifact(start) : undefined;
|
||||||
|
if (!artifact) return generate;
|
||||||
|
return config => {
|
||||||
|
if (config.sheet === "tokens" && exportedFor(native, artifact, config)) return artifact.css;
|
||||||
|
if (config.sheet === "scheme" && exportedFor(native, artifact, config))
|
||||||
|
return exportedScheme(artifact.theme, config.dark);
|
||||||
|
return generate(config);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncNative: M3CssFn | null | undefined;
|
||||||
|
|
||||||
|
/** The Rust `m3Css` loaded synchronously (`require`), once per process; `null` when it is unavailable. */
|
||||||
|
function nativeSync(): M3CssFn | null {
|
||||||
|
if (syncNative !== undefined) return syncNative;
|
||||||
|
try {
|
||||||
|
syncNative = usable(createRequire(bridgeFile())("@aphrody/bun") as AphrodyBun) ?? null;
|
||||||
|
} catch {
|
||||||
|
syncNative = null;
|
||||||
|
}
|
||||||
|
return syncNative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A synchronous `M3CssFn` for `m3Bun({ css })`: the Rust `m3Css` when the native library loads, else
|
||||||
|
* {@link mcuM3Css}. Passing it is the caller's explicit choice of the TypeScript fallback.
|
||||||
|
*/
|
||||||
|
export const tsM3Css: M3CssFn = (config: M3CssConfig): string => (nativeSync() ?? mcuM3Css)(config);
|
||||||
|
|
||||||
|
interface ThemeArtifact {
|
||||||
|
path: string;
|
||||||
|
theme: NativeThemeExport;
|
||||||
|
css: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ThemeExport pair above `start` (`var/mirrors/design-sync/tokens.{json,css}`, or `$APHRODY_DESIGN_CORPUS`). */
|
||||||
|
async function themeArtifact(start: string): Promise<ThemeArtifact | undefined> {
|
||||||
|
const native = await nativeBridge();
|
||||||
|
const path = native.findNativeTheme(start);
|
||||||
|
if (!path || !(await Bun.file(path).exists())) return undefined;
|
||||||
|
return { path, ...native.readNativeTheme(path) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedFor = (native: M3NativeBridge, artifact: ThemeArtifact, config: M3CssConfig): boolean =>
|
||||||
|
native.nativeThemeMatches({ ...config, sheet: "tokens" }, artifact.theme);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ThemeExport of the Rust token owner above `start`, validated against its CSS twin, when it was exported for
|
||||||
|
* this seed, variant and contrast.
|
||||||
|
*/
|
||||||
|
export async function nativeThemeExport(
|
||||||
|
start: string,
|
||||||
|
theme: Omit<M3CssConfig, "sheet">,
|
||||||
|
): Promise<{ path: string; theme: NativeThemeExport } | undefined> {
|
||||||
|
const artifact = await themeArtifact(start);
|
||||||
|
if (!artifact || !exportedFor(await nativeBridge(), artifact, { ...theme, sheet: "tokens" })) return undefined;
|
||||||
|
return { path: artifact.path, theme: artifact.theme };
|
||||||
|
}
|
||||||
|
|
||||||
|
const kebab = (role: string): string => role.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||||
|
|
||||||
|
/** A `scheme` sheet (one mode) from the roles of a ThemeExport, colours unchanged. */
|
||||||
|
export function exportedScheme(theme: NativeThemeExport, dark = false): string {
|
||||||
|
const roles = Object.entries(theme[dark ? "dark" : "light"]).map(
|
||||||
|
([role, value]) => ` --md-sys-color-${kebab(role)}: ${value};`,
|
||||||
|
);
|
||||||
|
return [":root {", ...roles, "}", ""].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun create <template> <dir>`: a new project from `@aphrody/scaffold`, the single scaffolder of the UI stack
|
||||||
|
// (`web`, `app`, `spa`, `example`). It writes nothing over existing files and, like `aphrody-scaffold`, only into a
|
||||||
|
// declared member of the Aphrody/M3 workspace.
|
||||||
|
import type { TemplateName } from "@aphrody/scaffold";
|
||||||
|
import { basename, resolve } from "node:path";
|
||||||
|
import { loadTool } from "./tools-load.ts";
|
||||||
|
|
||||||
|
export interface CreateOptions {
|
||||||
|
template: TemplateName;
|
||||||
|
dir: string;
|
||||||
|
/** npm name (default: from the directory name). */
|
||||||
|
name?: string;
|
||||||
|
/** `app` template: `vite` (native Bun web, default) or `tauri`. */
|
||||||
|
target?: "tauri" | "vite";
|
||||||
|
/** List the files instead of writing them. */
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReport {
|
||||||
|
template: TemplateName;
|
||||||
|
dir: string;
|
||||||
|
name: string;
|
||||||
|
/** Relative paths when `dryRun`, absolute paths written otherwise. */
|
||||||
|
files: string[];
|
||||||
|
written: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The templates `m3-bun create` offers, with their summaries. */
|
||||||
|
export async function createTemplates(): Promise<ReadonlyArray<{ name: TemplateName; summary: string }>> {
|
||||||
|
return (await loadTool("@aphrody/scaffold", () => import("@aphrody/scaffold"))).TEMPLATES;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scaffold = typeof import("@aphrody/scaffold");
|
||||||
|
|
||||||
|
function scaffoldRequest(options: CreateOptions, name: string, scaffold: Scaffold): Parameters<Scaffold["renderTemplate"]>[0] {
|
||||||
|
switch (options.template) {
|
||||||
|
case "example":
|
||||||
|
return { template: "example", name };
|
||||||
|
case "app":
|
||||||
|
return { template: "app", name, target: options.target ?? "vite", modernizeM3: true };
|
||||||
|
case "spa":
|
||||||
|
return { template: "spa", name };
|
||||||
|
case "web":
|
||||||
|
return { template: "web", name, m3Version: scaffold.ownerM3Version() };
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun create: unknown template ${String(options.template)} (${scaffold.TEMPLATES.map(t => t.name).join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a template and, unless `dryRun`, write it with `scaffold()`. */
|
||||||
|
export async function createApp(options: CreateOptions): Promise<CreateReport> {
|
||||||
|
const scaffold = await loadTool("@aphrody/scaffold", () => import("@aphrody/scaffold"));
|
||||||
|
const dir = resolve(options.dir);
|
||||||
|
const name = options.name ?? scaffold.npmName(basename(dir)) ?? scaffold.EXAMPLE_NAME;
|
||||||
|
const request = scaffoldRequest(options, name, scaffold);
|
||||||
|
if (options.dryRun) {
|
||||||
|
return {
|
||||||
|
template: options.template,
|
||||||
|
dir,
|
||||||
|
name,
|
||||||
|
files: Object.keys(scaffold.renderTemplate(request)).toSorted(),
|
||||||
|
written: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { template: options.template, dir, name, files: await scaffold.scaffold(dir, request), written: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun design "<brief>"`: the design compiler of `@aphrody/m3-design`. A natural-language brief becomes an
|
||||||
|
// `@aphrody/m3-react` layout (`src/index.tsx`), an HCT theme sheet and W3C design tokens, with a self-critique.
|
||||||
|
import type { GenerationResult } from "@aphrody/m3-design";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { loadTool } from "./tools-load.ts";
|
||||||
|
import { writeFiles } from "./util.ts";
|
||||||
|
|
||||||
|
export interface DesignOptions {
|
||||||
|
prompt: string;
|
||||||
|
/** Seed colour `#rrggbb` (default: from the brief, else the M3 baseline). */
|
||||||
|
color?: string;
|
||||||
|
/** A design system shipped with `@aphrody/m3-design` (`design-systems/<id>`), e.g. `material`. */
|
||||||
|
designSystem?: string;
|
||||||
|
/** Validate the generated spec with the native validator of `@aphrody/bun`. */
|
||||||
|
validate?: boolean;
|
||||||
|
/** Write the files below this directory; without it nothing is written. */
|
||||||
|
out?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesignReport {
|
||||||
|
brief: GenerationResult["brief"];
|
||||||
|
seed: string;
|
||||||
|
files: Array<{ path: string; bytes: number }>;
|
||||||
|
critique: GenerationResult["critique"];
|
||||||
|
/** Absolute paths written (empty without `out`). */
|
||||||
|
written: string[];
|
||||||
|
result: GenerationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compile a design brief and optionally write the generated files. */
|
||||||
|
export async function designApp(options: DesignOptions): Promise<DesignReport> {
|
||||||
|
if (!options.prompt.trim()) throw new Error("m3-bun design: the brief is empty");
|
||||||
|
const { DesignCompiler } = await loadTool("@aphrody/m3-design", () => import("@aphrody/m3-design"));
|
||||||
|
const { loadDesignSystemFromDisk } = await import("@aphrody/m3-design/src/node.ts");
|
||||||
|
const validateSpec = options.validate
|
||||||
|
? (await loadTool("@aphrody/bun", () => import("@aphrody/m3-design/src/native.ts"))).nativeSpecValidator
|
||||||
|
: undefined;
|
||||||
|
const compiler = new DesignCompiler({
|
||||||
|
loadDesignSystem: loadDesignSystemFromDisk,
|
||||||
|
...(validateSpec ? { validateSpec } : {}),
|
||||||
|
});
|
||||||
|
const result = compiler.compile(options.prompt, options.color, options.designSystem);
|
||||||
|
const written = options.out
|
||||||
|
? await writeFiles(
|
||||||
|
resolve(options.out),
|
||||||
|
result.files.map(file => ({ path: file.path, content: file.content })),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
brief: result.brief,
|
||||||
|
seed: result.theme.seed,
|
||||||
|
files: result.files.map(file => ({ path: file.path, bytes: Buffer.byteLength(file.content) })),
|
||||||
|
critique: result.critique,
|
||||||
|
written,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun lib [dir]`: build an M3 library package in-process with the shared preset of `@aphrody/m3-config/bun-build`
|
||||||
|
// (`m3BunBuild`): its `build.config.ts` (`m3Package`), the package's dependencies left external and `'use client'`
|
||||||
|
// on the emitted modules. Outside the M3 workspace this is the same build `scripts/build.ts` runs inside it.
|
||||||
|
import type { M3BunBuildOptions, M3PackageConfig } from "@aphrody/m3-config/bun-build";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import { loadTool } from "./tools-load.ts";
|
||||||
|
|
||||||
|
export interface LibraryOptions extends Omit<M3BunBuildOptions, "cwd"> {
|
||||||
|
/** Package directory (default: the working directory). */
|
||||||
|
dir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryReport {
|
||||||
|
success: boolean;
|
||||||
|
outDir: string;
|
||||||
|
/** The `build.config.ts` applied, if the package has one. */
|
||||||
|
config?: string;
|
||||||
|
files: Array<{ path: string; bytes: number; zstdBytes: number }>;
|
||||||
|
logs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a library package with `m3BunBuild`; `options` override its `build.config.ts`. */
|
||||||
|
export async function buildLibrary(options: LibraryOptions = {}): Promise<LibraryReport> {
|
||||||
|
const { dir: dirOption, ...overrides } = options;
|
||||||
|
const dir = resolve(dirOption ?? process.cwd());
|
||||||
|
const { m3BunBuild } = await loadTool("@aphrody/m3-config", () => import("@aphrody/m3-config/bun-build"));
|
||||||
|
const configFile = join(dir, "build.config.ts");
|
||||||
|
const config = existsSync(configFile)
|
||||||
|
? ((await import(Bun.pathToFileURL(configFile).href)) as { default?: M3PackageConfig }).default
|
||||||
|
: undefined;
|
||||||
|
const result = await m3BunBuild({ ...config, ...overrides, cwd: dir });
|
||||||
|
const files = await Promise.all(
|
||||||
|
result.outputs.map(async output => {
|
||||||
|
const bytes = await Bun.file(output.path).bytes();
|
||||||
|
return {
|
||||||
|
path: relative(result.outDir, output.path).replaceAll("\\", "/"),
|
||||||
|
bytes: bytes.length,
|
||||||
|
zstdBytes: Bun.zstdCompressSync(bytes).length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
outDir: result.outDir,
|
||||||
|
...(config ? { config: configFile } : {}),
|
||||||
|
files: files.toSorted((a, b) => a.path.localeCompare(b.path)),
|
||||||
|
logs: result.logs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun lint`: the rules of `@aphrody/eslint-plugin-m3` run by oxlint (its `jsPlugins`), on the app's sources.
|
||||||
|
// The config is generated per run from the plugin's own `recommended` or `strict` preset.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { loadTool } from "./tools-load.ts";
|
||||||
|
|
||||||
|
export type LintPreset = "recommended" | "strict";
|
||||||
|
|
||||||
|
export interface LintOptions {
|
||||||
|
/** App directory (default: the working directory). */
|
||||||
|
dir?: string;
|
||||||
|
/** Files or directories to lint, relative to `dir` (default: `dir`). */
|
||||||
|
paths?: string[];
|
||||||
|
preset?: LintPreset;
|
||||||
|
/** Apply the autofixes (`valid-icon-name`, `no-mui-prop-on-md`). */
|
||||||
|
fix?: boolean;
|
||||||
|
/** Also run oxlint's default `correctness` rules (default: only the m3 rules). */
|
||||||
|
correctness?: boolean;
|
||||||
|
/** The oxlint executable (default: the `oxlint` package, else `oxlint` on PATH). */
|
||||||
|
oxlint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LintDiagnostic {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
/** `m3/no-sx-prop`, or `eslint/no-unused-vars` with `correctness`. */
|
||||||
|
rule: string;
|
||||||
|
severity: "error" | "warning";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LintReport {
|
||||||
|
diagnostics: LintDiagnostic[];
|
||||||
|
errors: number;
|
||||||
|
warnings: number;
|
||||||
|
files: number;
|
||||||
|
rules: number;
|
||||||
|
/** oxlint's exit code: 0 when no error. */
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OxlintJson {
|
||||||
|
diagnostics: Array<{
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
severity: string;
|
||||||
|
filename: string;
|
||||||
|
labels?: Array<{ span: { line: number; column: number } }>;
|
||||||
|
}>;
|
||||||
|
number_of_files: number;
|
||||||
|
number_of_rules: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The oxlint executable: the option, the `bin` of an `oxlint` package resolvable from `from` or m3-bun, else
|
||||||
|
* `oxlint` on PATH. The package comes first: `jsPlugins` run only through its JS entry, a standalone native
|
||||||
|
* oxlint ignores them.
|
||||||
|
*/
|
||||||
|
export function oxlintBinary(explicit?: string, from?: string): string {
|
||||||
|
if (explicit) return explicit;
|
||||||
|
for (const base of [from, import.meta.dir]) {
|
||||||
|
if (!base) continue;
|
||||||
|
try {
|
||||||
|
const manifest = Bun.resolveSync("oxlint/package.json", base);
|
||||||
|
const { bin } = require(manifest) as { bin: string | Record<string, string> };
|
||||||
|
return resolve(
|
||||||
|
manifest,
|
||||||
|
"..",
|
||||||
|
typeof bin === "string" ? bin : (bin.oxlint ?? Object.values(bin)[0]!),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const onPath = Bun.which("oxlint");
|
||||||
|
if (onPath) return onPath;
|
||||||
|
throw new Error("m3-bun lint: oxlint is not installed (bun add -d oxlint, or put it on PATH)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A script `bin` (the package's JS entry) runs under this Bun; a native executable runs directly. */
|
||||||
|
function oxlintArgv(binary: string): string[] {
|
||||||
|
if (/\.[cm]?js$/i.test(binary)) return [process.execPath, binary];
|
||||||
|
let head = "";
|
||||||
|
try {
|
||||||
|
head = readFileSync(binary).subarray(0, 64).toString("latin1");
|
||||||
|
} catch {}
|
||||||
|
return /^#!.*\b(node|bun)\b/.test(head) ? [process.execPath, binary] : [binary];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `m3(no-sx-prop)` -> `m3/no-sx-prop`. */
|
||||||
|
export const ruleName = (code: string): string => code.replace(/^([\w-]+)\((.+)\)$/, "$1/$2");
|
||||||
|
|
||||||
|
/** Lint an app with the m3 rules. */
|
||||||
|
export async function lintApp(options: LintOptions = {}): Promise<LintReport> {
|
||||||
|
const dir = resolve(options.dir ?? process.cwd());
|
||||||
|
const plugin = (
|
||||||
|
await loadTool("@aphrody/eslint-plugin-m3", () => import("@aphrody/eslint-plugin-m3"))
|
||||||
|
).default;
|
||||||
|
const preset = options.preset ?? "recommended";
|
||||||
|
const rules = plugin.configs[preset]?.rules;
|
||||||
|
if (!rules)
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun lint: unknown preset ${preset} (${Object.keys(plugin.configs).join(", ")})`,
|
||||||
|
);
|
||||||
|
const configDir = await mkdtemp(join(tmpdir(), "m3-bun-lint-"));
|
||||||
|
try {
|
||||||
|
const config = join(configDir, ".oxlintrc.json");
|
||||||
|
await Bun.write(
|
||||||
|
config,
|
||||||
|
JSON.stringify({
|
||||||
|
jsPlugins: [
|
||||||
|
fileURLToPath(import.meta.resolve("@aphrody/eslint-plugin-m3")).replaceAll("\\", "/"),
|
||||||
|
],
|
||||||
|
...(options.correctness ? {} : { categories: { correctness: "off" } }),
|
||||||
|
rules,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const binary = oxlintBinary(options.oxlint, dir);
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
...oxlintArgv(binary),
|
||||||
|
"-c",
|
||||||
|
config,
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--ignore-pattern",
|
||||||
|
"**/dist/**",
|
||||||
|
...(options.fix ? ["--fix"] : []),
|
||||||
|
...(options.paths?.length ? options.paths : ["."]),
|
||||||
|
],
|
||||||
|
{ windowsHide: true, cwd: dir, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
let json: OxlintJson;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(stdout) as OxlintJson;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun lint: oxlint exited ${exitCode} without a JSON report:\n${stderr || stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!options.correctness && json.number_of_rules < Object.keys(rules).length)
|
||||||
|
throw new Error(
|
||||||
|
`m3-bun lint: ${binary} enabled ${json.number_of_rules} of the ${Object.keys(rules).length} m3 rules: it does not run jsPlugins (a standalone native oxlint); install the oxlint package (bun add -d oxlint) or pass one that does`,
|
||||||
|
);
|
||||||
|
const diagnostics = json.diagnostics.map((d): LintDiagnostic => {
|
||||||
|
const span = d.labels?.[0]?.span;
|
||||||
|
return {
|
||||||
|
file: d.filename.replaceAll("\\", "/"),
|
||||||
|
line: span?.line ?? 0,
|
||||||
|
column: span?.column ?? 0,
|
||||||
|
rule: ruleName(d.code),
|
||||||
|
severity: d.severity === "error" ? "error" : "warning",
|
||||||
|
message: d.message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
diagnostics,
|
||||||
|
errors: diagnostics.filter((d) => d.severity === "error").length,
|
||||||
|
warnings: diagnostics.filter((d) => d.severity === "warning").length,
|
||||||
|
files: json.number_of_files,
|
||||||
|
rules: json.number_of_rules,
|
||||||
|
exitCode,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await rm(configDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The m3 tool packages behind `m3-bun lint|migrate|create|design|og|os-theme|lib` are imported on first use, so the
|
||||||
|
// other commands never load them. A package that is not installed is an error that names it.
|
||||||
|
|
||||||
|
/** Import a tool package through its own `import()` (kept literal for the bundler), naming the package when absent. */
|
||||||
|
export async function loadTool<T>(name: string, load: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await load();
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: string }).code;
|
||||||
|
if ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && String(error).includes(`'${name}'`)) {
|
||||||
|
throw new Error(`m3-bun: ${name} is not installed; add it next to @aphrody/m3-bun (bun add ${name})`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun migrate`: MUI React to `@aphrody/m3-react` with the codemods of `@aphrody/m3-codemods` (in place, on the
|
||||||
|
// app's `.tsx`/`.jsx`), and a Next.js app to a Bun M3 app with `@aphrody/m3-next-migrate` (`--next <source>`).
|
||||||
|
import type { MigrateProjectReport } from "@aphrody/m3-next-migrate";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { loadTool } from "./tools-load.ts";
|
||||||
|
|
||||||
|
export type CodemodKind = "mui" | "icons";
|
||||||
|
|
||||||
|
export interface MigrateFile {
|
||||||
|
/** Path relative to the app directory, `/`-separated. */
|
||||||
|
path: string;
|
||||||
|
/** `MIGRATION-TODO` notes the codemod left for a human. */
|
||||||
|
diagnostics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrateReport {
|
||||||
|
/** Files the codemod changed (written unless `dryRun`). */
|
||||||
|
changed: MigrateFile[];
|
||||||
|
scanned: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrateOptions {
|
||||||
|
dir?: string;
|
||||||
|
/** Glob of the files to transform, relative to `dir` (default every `.tsx`/`.jsx` outside node_modules and dist). */
|
||||||
|
glob?: string;
|
||||||
|
/** `mui` (components, imports, props) or `icons` (MUI icons to Material Symbols). Default `mui`. */
|
||||||
|
kind?: CodemodKind;
|
||||||
|
/** Report without writing. */
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIP = /(^|\/)(node_modules|dist|bin|release)\//;
|
||||||
|
|
||||||
|
/** Run an `@aphrody/m3-codemods` transform over an app's sources. */
|
||||||
|
export async function migrateApp(options: MigrateOptions = {}): Promise<MigrateReport> {
|
||||||
|
const dir = resolve(options.dir ?? process.cwd());
|
||||||
|
const { transformSource } = await loadTool("@aphrody/m3-codemods", () => import("@aphrody/m3-codemods"));
|
||||||
|
const kind = options.kind ?? "mui";
|
||||||
|
const dryRun = options.dryRun ?? false;
|
||||||
|
const paths = (
|
||||||
|
await Array.fromAsync(new Bun.Glob(options.glob ?? "**/*.{tsx,jsx}").scan({ cwd: dir, onlyFiles: true }))
|
||||||
|
)
|
||||||
|
.map(path => path.replaceAll("\\", "/"))
|
||||||
|
.filter(path => !SKIP.test(path))
|
||||||
|
.toSorted();
|
||||||
|
const changed: MigrateFile[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
const file = Bun.file(resolve(dir, path));
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- One source at a time: each is read, transformed and written in turn.
|
||||||
|
const source = await file.text();
|
||||||
|
const result = transformSource(source, kind);
|
||||||
|
if (result.source === source) continue;
|
||||||
|
changed.push({ path, diagnostics: result.diagnostics });
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Written right after its own transform.
|
||||||
|
if (!dryRun) await Bun.write(file, result.source);
|
||||||
|
}
|
||||||
|
return { changed, scanned: paths.length, dryRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrateNextOptions {
|
||||||
|
/** The Next.js app. */
|
||||||
|
source: string;
|
||||||
|
/** Output directory, outside `source`. */
|
||||||
|
target: string;
|
||||||
|
name?: string;
|
||||||
|
/** Also run the MUI to M3 codemods on the migrated components. */
|
||||||
|
m3?: boolean;
|
||||||
|
/** Write the project (default: dry run, which only scans and transforms in memory). */
|
||||||
|
write?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a Next.js app with `migrateProject` of `@aphrody/m3-next-migrate`, on the native Bun web target. A dry run
|
||||||
|
* (the default) returns the scan, routes, transforms and the native requirements without creating files.
|
||||||
|
*/
|
||||||
|
export async function migrateNext(options: MigrateNextOptions): Promise<MigrateProjectReport> {
|
||||||
|
const { migrateProject } = await loadTool("@aphrody/m3-next-migrate", () => import("@aphrody/m3-next-migrate"));
|
||||||
|
const sourceDir = resolve(options.source);
|
||||||
|
const targetDir = resolve(options.target);
|
||||||
|
return migrateProject({
|
||||||
|
sourceDir,
|
||||||
|
targetDir,
|
||||||
|
target: "vite",
|
||||||
|
dryRun: options.write !== true,
|
||||||
|
modernizeM3: options.m3 ?? false,
|
||||||
|
...(options.name ? { projectName: options.name } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// `m3-bun og`: the Open Graph image of an app (1200 x 630), drawn with the native Skia canvas of `@aphrody/canvas`
|
||||||
|
// in the colour roles of the app's theme and the Google Sans Flex face of `@aphrody/m3-fonts`.
|
||||||
|
import { cssRequest } from "@aphrody/m3";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { loadApp, type M3App } from "./config.ts";
|
||||||
|
import { m3CssFor, type M3CssEngine } from "./theme-css.ts";
|
||||||
|
import { loadTool } from "./tools-load.ts";
|
||||||
|
|
||||||
|
export interface OgOptions {
|
||||||
|
dir?: string;
|
||||||
|
configFile?: string;
|
||||||
|
app?: M3App;
|
||||||
|
/** Headline (default: the app name). */
|
||||||
|
title?: string;
|
||||||
|
/** Second line (default: `v<version>`). */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Dark colour scheme. */
|
||||||
|
dark?: boolean;
|
||||||
|
/** Theme generator (default `native`: the Rust ThemeExport or `m3Css`); `mcu` asks for the TypeScript one. */
|
||||||
|
engine?: M3CssEngine;
|
||||||
|
/** Output file (default `<outdir>/og.png`); `.webp` writes WebP. */
|
||||||
|
out?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OgReport {
|
||||||
|
file: string;
|
||||||
|
bytes: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** The colour roles the image uses, `#rrggbb`. */
|
||||||
|
colors: Record<"surface" | "onSurface" | "onSurfaceVariant" | "primary" | "primaryContainer", string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `--md-sys-color-<role>: #rrggbb` declarations of a sheet, first occurrence wins, keyed by camelCase role. */
|
||||||
|
export function colorRoles(css: string): Record<string, string> {
|
||||||
|
const roles: Record<string, string> = {};
|
||||||
|
for (const [, role, value] of css.matchAll(/--md-sys-color-([a-z-]+)\s*:\s*(#[0-9a-f]{6})\b/gi)) {
|
||||||
|
const key = role!.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||||
|
roles[key] ??= value!.toLowerCase();
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE = "Google Sans Flex";
|
||||||
|
|
||||||
|
/** Draw and write the Open Graph image of an app. */
|
||||||
|
export async function ogImage(options: OgOptions = {}): Promise<OgReport> {
|
||||||
|
const app = options.app ?? (await loadApp(resolve(options.dir ?? process.cwd()), options.configFile));
|
||||||
|
const canvasModule = await loadTool("@aphrody/canvas", () => import("@aphrody/canvas"));
|
||||||
|
if (!canvasModule.isNativeCanvasAvailable()) {
|
||||||
|
throw new Error("m3-bun og: the native Skia addon of @aphrody/canvas is not installed for this platform");
|
||||||
|
}
|
||||||
|
const theme = app.m3.theme ?? {};
|
||||||
|
const generate = await m3CssFor(options.engine ?? "native", app.dir);
|
||||||
|
const roles = colorRoles(generate(cssRequest("scheme", { ...theme, dark: options.dark ?? false })));
|
||||||
|
const pick = (role: keyof OgReport["colors"]): string => {
|
||||||
|
const value = roles[role];
|
||||||
|
if (!value) throw new Error(`m3-bun og: the theme sheet has no --md-sys-color role ${role}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const colors: OgReport["colors"] = {
|
||||||
|
surface: pick("surface"),
|
||||||
|
onSurface: pick("onSurface"),
|
||||||
|
onSurfaceVariant: pick("onSurfaceVariant"),
|
||||||
|
primary: pick("primary"),
|
||||||
|
primaryContainer: pick("primaryContainer"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canvasModule.GlobalFonts.has(FACE)) {
|
||||||
|
canvasModule.GlobalFonts.registerFromPath(
|
||||||
|
Bun.resolveSync("@aphrody/m3-fonts/fonts/web/GoogleSansFlex-latin.woff2", import.meta.dir),
|
||||||
|
FACE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const width = options.width ?? 1200;
|
||||||
|
const height = options.height ?? 630;
|
||||||
|
const canvas = await canvasModule.createNativeCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const pad = Math.round(width / 15);
|
||||||
|
ctx.fillStyle = colors.surface;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = colors.primaryContainer;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(width - pad * 4, -pad, pad * 5, height + pad * 2, pad * 1.5);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = colors.primary;
|
||||||
|
ctx.fillRect(pad, pad, pad * 1.2, Math.round(pad / 5));
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
ctx.fillStyle = colors.onSurface;
|
||||||
|
ctx.font = `${Math.round(height / 6.5)}px "${FACE}"`;
|
||||||
|
ctx.fillText(options.title ?? app.name, pad, height * 0.55, width - pad * 6);
|
||||||
|
ctx.fillStyle = colors.onSurfaceVariant;
|
||||||
|
ctx.font = `${Math.round(height / 16)}px "${FACE}"`;
|
||||||
|
ctx.fillText(options.subtitle ?? `v${app.version}`, pad, height * 0.55 + height / 8, width - pad * 6);
|
||||||
|
|
||||||
|
const file = resolve(options.out ?? join(app.outdir, "og.png"));
|
||||||
|
const bytes = file.endsWith(".webp") ? await canvas.encode("webp") : await canvas.encode("png");
|
||||||
|
await Bun.write(file, bytes);
|
||||||
|
return { file, bytes: bytes.length, width, height, colors };
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Small helpers shared by the m3-bun modules.
|
||||||
|
import { lstat, mkdir } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
/** The yolo checkout: `$YOLO_ROOT`, else `~/yolo`. Host conventions and the runtime SDK are resolved under it. */
|
||||||
|
export function yoloRoot(): string {
|
||||||
|
return resolve(Bun.env.YOLO_ROOT ?? join(homedir(), "yolo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `@scope/name` -> `name`; anything not a valid DNS-ish label becomes `-`. */
|
||||||
|
export function slug(name: string): string {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.replace(/^@[^/]+\//, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || "app"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedFile {
|
||||||
|
/** Path relative to the target's output directory, `/`-separated. */
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
/** Unix mode (default 0o644). */
|
||||||
|
mode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write generated files below `dir`; returns the absolute paths written. Never touches anything else. */
|
||||||
|
export async function writeFiles(
|
||||||
|
dir: string,
|
||||||
|
files: ReadonlyArray<Omit<GeneratedFile, "content"> & { content: string | Uint8Array }>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const written: string[] = [];
|
||||||
|
/* eslint-disable no-await-in-loop -- Files and ancestor checks are ordered to preserve containment and repeated-path writes. */
|
||||||
|
for (const file of files) {
|
||||||
|
const target = resolve(dir, file.path);
|
||||||
|
const inside = relative(resolve(dir), target);
|
||||||
|
if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside))
|
||||||
|
throw new Error(`m3-bun: generated path escapes its directory: ${file.path}`);
|
||||||
|
let ancestor = resolve(dir);
|
||||||
|
for (const part of inside.split(sep)) {
|
||||||
|
ancestor = join(ancestor, part);
|
||||||
|
try {
|
||||||
|
if ((await lstat(ancestor)).isSymbolicLink())
|
||||||
|
throw new Error(`m3-bun: generated path contains a symlink: ${file.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mkdir(dirname(target), { recursive: true });
|
||||||
|
await Bun.write(target, file.content);
|
||||||
|
if (file.mode !== undefined && process.platform !== "win32")
|
||||||
|
await Bun.$`chmod ${file.mode.toString(8)} ${target}`.quiet();
|
||||||
|
written.push(target);
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop */
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hex SHA-256 of bytes or text. */
|
||||||
|
export function sha256(data: string | Uint8Array): string {
|
||||||
|
return new Bun.CryptoHasher("sha256").update(data).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a byte count as KiB with one decimal. */
|
||||||
|
export const kib = (bytes: number): string => `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
|
||||||
|
/** Shell-quote one argument for generated scripts. */
|
||||||
|
export function shq(value: string): string {
|
||||||
|
return /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GENERATED_NOTE =
|
||||||
|
"Generated by @aphrody/m3-bun. Review before use; m3-bun never applies it.";
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
defineM3Api,
|
||||||
|
jsonl,
|
||||||
|
m3Csrf,
|
||||||
|
normalizeApi,
|
||||||
|
readBody,
|
||||||
|
readJsonl,
|
||||||
|
reply,
|
||||||
|
requestCookies,
|
||||||
|
routesToFetch,
|
||||||
|
withCookies,
|
||||||
|
yaml,
|
||||||
|
} from "../src/api.ts";
|
||||||
|
|
||||||
|
const call = (handler: ReturnType<typeof routesToFetch>, path: string, init?: RequestInit) =>
|
||||||
|
handler(new Request(`http://m3.test${path}`, init));
|
||||||
|
|
||||||
|
describe("routesToFetch", () => {
|
||||||
|
const handler = routesToFetch({
|
||||||
|
"/api/hello/:name": (request) => Response.json({ hello: request.params.name }),
|
||||||
|
"/api/hello/world": () => Response.json({ hello: "static wins" }),
|
||||||
|
"/api/items": {
|
||||||
|
GET: () => Response.json([1, 2]),
|
||||||
|
POST: async (request) => Response.json(await request.json(), { status: 201 }),
|
||||||
|
},
|
||||||
|
"/api/files/*": (request) => new Response(new URL(request.url).pathname),
|
||||||
|
"/api/version": new Response("1.0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
test("params are decoded and static segments outrank them", async () => {
|
||||||
|
expect(await (await call(handler, "/api/hello/caf%C3%A9"))!.json()).toEqual({ hello: "café" });
|
||||||
|
expect(await (await call(handler, "/api/hello/world"))!.json()).toEqual({
|
||||||
|
hello: "static wins",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("methods dispatch, others answer 405 with Allow", async () => {
|
||||||
|
expect(await (await call(handler, "/api/items"))!.json()).toEqual([1, 2]);
|
||||||
|
const created = await call(handler, "/api/items", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ a: 1 }),
|
||||||
|
});
|
||||||
|
expect(created!.status).toBe(201);
|
||||||
|
const denied = await call(handler, "/api/items", { method: "DELETE" });
|
||||||
|
expect(denied!.status).toBe(405);
|
||||||
|
expect(denied!.headers.get("allow")).toBe("GET, POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a trailing /* matches everything below, a static Response is cloned per request", async () => {
|
||||||
|
expect(await (await call(handler, "/api/files/a/b/c.txt"))!.text()).toBe(
|
||||||
|
"/api/files/a/b/c.txt",
|
||||||
|
);
|
||||||
|
expect(await (await call(handler, "/api/version"))!.text()).toBe("1.0");
|
||||||
|
expect(await (await call(handler, "/api/version"))!.text()).toBe("1.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unmatched request resolves to undefined so a host can fall through to its assets", async () => {
|
||||||
|
expect(await call(handler, "/nothing")).toBeUndefined();
|
||||||
|
expect(await call(handler, "/api/hello")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchFallback = () => undefined;
|
||||||
|
|
||||||
|
describe("normalizeApi", () => {
|
||||||
|
test("routes only, routes plus fetch, and nothing", () => {
|
||||||
|
const routes = { "/api/a": () => new Response("a") };
|
||||||
|
expect(normalizeApi(routes)).toEqual({ routes });
|
||||||
|
expect(normalizeApi({ routes, fetch: fetchFallback })).toEqual({
|
||||||
|
routes,
|
||||||
|
fetch: fetchFallback,
|
||||||
|
});
|
||||||
|
expect(normalizeApi({ fetch: fetchFallback })).toEqual({ routes: {}, fetch: fetchFallback });
|
||||||
|
expect(normalizeApi(undefined)).toEqual({ routes: {} });
|
||||||
|
});
|
||||||
|
test("defineM3Api is the identity", () => {
|
||||||
|
const api = { "/api/x": () => new Response("x") };
|
||||||
|
expect(defineM3Api(api)).toBe(api);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cookies (Bun.CookieMap)", () => {
|
||||||
|
test("routes read request.cookies and their changes become Set-Cookie headers", async () => {
|
||||||
|
const handler = routesToFetch({
|
||||||
|
"/api/visit": (request) => {
|
||||||
|
const visits = Number(request.cookies.get("visits") ?? 0) + 1;
|
||||||
|
request.cookies.set("visits", String(visits), { httpOnly: true, path: "/" });
|
||||||
|
return Response.json({ visits });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = (await call(handler, "/api/visit", { headers: { cookie: "visits=2; other=x" } }))!;
|
||||||
|
expect(await res.json()).toEqual({ visits: 3 });
|
||||||
|
expect(res.headers.getSetCookie()).toEqual(["visits=3; Path=/; HttpOnly; SameSite=Lax"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requestCookies parses the header; withCookies leaves untouched maps alone", () => {
|
||||||
|
const cookies = requestCookies(new Request("http://m3.test/", { headers: { cookie: "a=1; b=two" } }));
|
||||||
|
expect(cookies.get("b")).toBe("two");
|
||||||
|
const plain = new Response("x");
|
||||||
|
expect(withCookies(plain, cookies)).toBe(plain);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("m3Csrf (Bun.CSRF)", () => {
|
||||||
|
const csrf = m3Csrf({ secret: "s3cret", sessionOf: (request) => request.cookies.get("sid") ?? undefined });
|
||||||
|
const handler = routesToFetch({
|
||||||
|
"/api/notes": csrf.protect({ GET: () => new Response("list"), POST: () => new Response("created", { status: 201 }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safe methods pass, unsafe ones need a token of the same session", async () => {
|
||||||
|
expect(await (await call(handler, "/api/notes"))!.text()).toBe("list");
|
||||||
|
expect((await call(handler, "/api/notes", { method: "POST" }))!.status).toBe(403);
|
||||||
|
const token = csrf.token("session-a");
|
||||||
|
const post = (sid: string) =>
|
||||||
|
call(handler, "/api/notes", { method: "POST", headers: { cookie: `sid=${sid}`, "x-csrf-token": token } });
|
||||||
|
expect((await post("session-a"))!.status).toBe(201);
|
||||||
|
expect((await post("session-b"))!.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = (accept: string) => new Request("http://m3.test/", { headers: { accept } });
|
||||||
|
async function* rows() {
|
||||||
|
yield 1;
|
||||||
|
yield { two: 2 };
|
||||||
|
}
|
||||||
|
const post = (type: string, text: string) =>
|
||||||
|
readBody(new Request("http://m3.test/", { method: "POST", headers: { "content-type": type }, body: text }));
|
||||||
|
|
||||||
|
describe("content negotiation (Bun.YAML, Bun.JSONL)", () => {
|
||||||
|
test("reply answers YAML, JSON Lines for iterables, else JSON, and varies on Accept", async () => {
|
||||||
|
const data = [
|
||||||
|
{ id: 1, name: "a" },
|
||||||
|
{ id: 2, name: "b" },
|
||||||
|
];
|
||||||
|
const asYaml = reply(data, request("application/yaml"));
|
||||||
|
expect(asYaml.headers.get("content-type")).toStartWith("application/yaml");
|
||||||
|
expect(Bun.YAML.parse(await asYaml.text())).toEqual(data);
|
||||||
|
const asLines = reply(data, request("application/jsonl"));
|
||||||
|
expect(await asLines.text()).toBe('{"id":1,"name":"a"}\n{"id":2,"name":"b"}\n');
|
||||||
|
const asJson = reply({ ok: true }, request("application/jsonl"));
|
||||||
|
expect(await asJson.json()).toEqual({ ok: true });
|
||||||
|
expect(asJson.headers.get("vary")).toBe("Accept");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("jsonl streams async iterables; yaml keeps a given content type", async () => {
|
||||||
|
expect(await jsonl(rows()).text()).toBe('1\n{"two":2}\n');
|
||||||
|
const typed = yaml({ a: 1 }, { headers: { "Content-Type": "text/yaml" } });
|
||||||
|
expect(typed.headers.get("content-type")).toBe("text/yaml");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readJsonl reads values across chunk boundaries; readBody follows Content-Type", async () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const part of ['{"a":', '1}\n{"b"', ":2}\n3"]) controller.enqueue(encoder.encode(part));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const streamed = new Request("http://m3.test/", { method: "POST", body, duplex: "half" } as RequestInit);
|
||||||
|
expect(await Array.fromAsync(readJsonl(streamed))).toEqual([{ a: 1 }, { b: 2 }, 3]);
|
||||||
|
expect(await post("application/yaml", "a: 1\nb: [x, y]\n")).toEqual({ a: 1, b: ["x", "y"] });
|
||||||
|
expect(await post("application/x-ndjson", '{"a":1}\n{"a":2}\n')).toEqual([{ a: 1 }, { a: 2 }]);
|
||||||
|
expect(await post("application/x-www-form-urlencoded", "a=1&b=2")).toEqual({ a: "1", b: "2" });
|
||||||
|
expect(await post("application/json", '{"x":true}')).toEqual({ x: true });
|
||||||
|
await expect(post("application/jsonl", '{"a":1}\n{oops}\n')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
buildApp,
|
||||||
|
checkEngine,
|
||||||
|
hoistHtml,
|
||||||
|
listBuild,
|
||||||
|
minifyOptions,
|
||||||
|
readManifest,
|
||||||
|
} from "../src/build.ts";
|
||||||
|
import { sha256 } from "../src/util.ts";
|
||||||
|
import { cleanup, fixtureCopy } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("hoistHtml", () => {
|
||||||
|
test("strips the directory depth the entry sat in, from every src and href", () => {
|
||||||
|
const html =
|
||||||
|
'<link rel="stylesheet" href="../chunk-a.css"><script type="module" src="../chunk-b.js"></script><a href="../../x/y">';
|
||||||
|
expect(hoistHtml(html, 1)).toBe(
|
||||||
|
'<link rel="stylesheet" href="./chunk-a.css"><script type="module" src="./chunk-b.js"></script><a href="../x/y">',
|
||||||
|
);
|
||||||
|
expect(hoistHtml(html, 2)).toBe(
|
||||||
|
'<link rel="stylesheet" href="./chunk-a.css"><script type="module" src="./chunk-b.js"></script><a href="./x/y">',
|
||||||
|
);
|
||||||
|
expect(hoistHtml(html, 0)).toBe(html);
|
||||||
|
});
|
||||||
|
test("absolute and external references are left alone", () => {
|
||||||
|
const html = '<script src="https://cdn.example.com/x.js"></script><img src="/logo.svg">';
|
||||||
|
expect(hoistHtml(html, 1)).toBe(html);
|
||||||
|
});
|
||||||
|
test("only attributes are rewritten: script text and comments that look like attributes stay as they are", () => {
|
||||||
|
const html =
|
||||||
|
'<!-- <img src="../c.png"> --><script>document.write(\x27<img src="../a.png">\x27)</script><img data-src="../b.png" src="../b.png">';
|
||||||
|
expect(hoistHtml(html, 1)).toBe(
|
||||||
|
'<!-- <img src="../c.png"> --><script>document.write(\x27<img src="../a.png">\x27)</script><img data-src="../b.png" src="./b.png">',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkEngine", () => {
|
||||||
|
test("warns when the running Bun does not satisfy engines.bun of the app", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
expect(checkEngine(dir, "1.4.3")).toBeUndefined();
|
||||||
|
const manifest = await Bun.file(join(dir, "package.json")).json();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "package.json"),
|
||||||
|
JSON.stringify({ ...manifest, engines: { bun: ">=1.5.0" } }),
|
||||||
|
);
|
||||||
|
expect(checkEngine(dir, "1.5.2")).toBeUndefined();
|
||||||
|
expect(checkEngine(dir, "1.4.3")).toContain(">=1.5.0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minifyOptions", () => {
|
||||||
|
test("none, basic and full", () => {
|
||||||
|
expect(minifyOptions("none")).toBe(false);
|
||||||
|
expect(minifyOptions("basic")).toEqual({ whitespace: true, syntax: true, identifiers: false });
|
||||||
|
expect(minifyOptions("full")).toEqual({ whitespace: true, syntax: true, identifiers: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildApp", () => {
|
||||||
|
test("builds the fixture to a deployable layout: index.html at the root, hashed assets, manifest", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const report = await buildApp({ dir });
|
||||||
|
const names = readdirSync(report.outdir).toSorted();
|
||||||
|
expect(names).toContain("index.html");
|
||||||
|
expect(names).toContain("m3-bun.manifest.json");
|
||||||
|
expect(names.some((n) => /^chunk-[a-z0-9]{8}\.js$/.test(n))).toBe(true);
|
||||||
|
expect(names.some((n) => /^chunk-[a-z0-9]{8}\.css$/.test(n))).toBe(true);
|
||||||
|
expect(existsSync(join(report.outdir, "src"))).toBe(false);
|
||||||
|
|
||||||
|
const html = readFileSync(join(report.outdir, "index.html"), "utf8");
|
||||||
|
expect(html).toContain('href="./chunk-');
|
||||||
|
expect(html).toContain('src="./chunk-');
|
||||||
|
expect(html).not.toContain("../");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the manifest lists every file with true sizes and hashes, and a build id derived from them", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const report = await buildApp({ dir });
|
||||||
|
const manifest = readManifest(report.outdir)!;
|
||||||
|
expect(manifest.schema).toBe("m3-bun.manifest/1");
|
||||||
|
expect(manifest).toMatchObject({
|
||||||
|
name: "hello-m3",
|
||||||
|
version: "1.2.3",
|
||||||
|
minify: "full",
|
||||||
|
entry: "index.html",
|
||||||
|
bun: Bun.version,
|
||||||
|
});
|
||||||
|
expect(manifest.files.map((f) => f.path)).toEqual(
|
||||||
|
readdirSync(report.outdir)
|
||||||
|
.filter((n) => n !== "m3-bun.manifest.json")
|
||||||
|
.toSorted(),
|
||||||
|
);
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const bytes = new Uint8Array(readFileSync(join(report.outdir, file.path)));
|
||||||
|
expect(file.bytes).toBe(bytes.length);
|
||||||
|
expect(file.sha256).toBe(sha256(bytes));
|
||||||
|
expect(file.gzipBytes).toBe(Bun.gzipSync(bytes).length);
|
||||||
|
expect(file.zstdBytes).toBe(Bun.zstdCompressSync(bytes).length);
|
||||||
|
}
|
||||||
|
expect(report.totalZstdBytes).toBe(manifest.files.reduce((n, f) => n + f.zstdBytes, 0));
|
||||||
|
expect(report.memory.heapSize).toBeGreaterThan(0);
|
||||||
|
expect(report.memory.objectCount).toBeGreaterThan(0);
|
||||||
|
expect(report.memory.peakRss).toBeGreaterThanOrEqual(report.memory.rss);
|
||||||
|
expect(report.warnings).toEqual([]);
|
||||||
|
expect(manifest.buildId).toBe(
|
||||||
|
sha256(manifest.files.map((f) => `${f.path}:${f.sha256}`).join("\n")).slice(0, 16),
|
||||||
|
);
|
||||||
|
expect(await listBuild(report.outdir)).toEqual(manifest.files);
|
||||||
|
expect(report.totalBytes).toBe(manifest.files.reduce((n, f) => n + f.bytes, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the one plugin is in the build: theme sheet from the seed, the icon as an SVG string", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const report = await buildApp({ dir });
|
||||||
|
const css = readFileSync(
|
||||||
|
join(report.outdir, report.manifest.files.find((f) => f.path.endsWith(".css"))!.path),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(css).toContain("--md-sys-color-primary:#65558f");
|
||||||
|
// The Rust token sheet's `@theme inline` twins are compiled by Tailwind, not left to Bun's CSS parser.
|
||||||
|
expect(css).not.toContain("@theme");
|
||||||
|
const js = readFileSync(
|
||||||
|
join(report.outdir, report.manifest.files.find((f) => f.path.endsWith(".js"))!.path),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(js).toContain('<svg xmlns="http://www.w3.org/2000/svg"');
|
||||||
|
expect(js).toContain("M240-200h120v-240h240v240h120v-360L480-740");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minify levels shrink the output; the same inputs give the same file contents", async () => {
|
||||||
|
const none = await buildApp({ dir: fixtureCopy(), minify: "none" });
|
||||||
|
const full = await buildApp({ dir: fixtureCopy(), minify: "full" });
|
||||||
|
expect(full.totalBytes).toBeLessThan(none.totalBytes);
|
||||||
|
expect(none.manifest.minify).toBe("none");
|
||||||
|
const again = await buildApp({ dir: fixtureCopy(), minify: "full" });
|
||||||
|
// Bun derives chunk file names in a way that differs between builds in one process, so compare contents.
|
||||||
|
const contents = (r: typeof full) =>
|
||||||
|
r.manifest.files
|
||||||
|
.filter((f) => !f.path.endsWith(".html"))
|
||||||
|
.map((f) => `${f.bytes}:${f.sha256}`)
|
||||||
|
.toSorted();
|
||||||
|
expect(contents(again)).toEqual(contents(full));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two builds in separate processes give the same build id: the build is reproducible", async () => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const proc = Bun.spawnSync(
|
||||||
|
["bun", join(import.meta.dir, "../bin/m3-bun.ts"), "build", "--json"],
|
||||||
|
{ windowsHide: true, cwd: dir, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
expect(proc.exitCode).toBe(0);
|
||||||
|
ids.push(
|
||||||
|
(JSON.parse(proc.stdout.toString()) as { manifest: { buildId: string } }).manifest.buildId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(ids[0]).toBe(ids[1]!);
|
||||||
|
expect(ids[0]).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rebuild replaces the output directory instead of accumulating files", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const first = await buildApp({ dir });
|
||||||
|
await Bun.write(join(first.outdir, "stale.txt"), "old");
|
||||||
|
const second = await buildApp({ dir });
|
||||||
|
expect(existsSync(join(second.outdir, "stale.txt"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bunfigHasPlugin, parseArgs, runCli } from "../src/cli.ts";
|
||||||
|
import { cleanup, FIXTURE, fixtureCopy, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
async function capture(fn: () => Promise<number>): Promise<{ code: number; text: string }> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const log = console.log;
|
||||||
|
console.log = (...args: unknown[]) => void lines.push(args.join(" "));
|
||||||
|
try {
|
||||||
|
return { code: await fn(), text: lines.join("\n") };
|
||||||
|
} finally {
|
||||||
|
console.log = log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseArgs", () => {
|
||||||
|
test("command, positionals and flags with values or booleans", () => {
|
||||||
|
const parsed = parseArgs([
|
||||||
|
"deploy",
|
||||||
|
"docker",
|
||||||
|
"nginx",
|
||||||
|
"--out",
|
||||||
|
"dist-deploy",
|
||||||
|
"--domain=a.example.com",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
expect(parsed.command).toBe("deploy");
|
||||||
|
expect(parsed.positionals).toEqual(["docker", "nginx"]);
|
||||||
|
expect(parsed.flags.get("out")).toBe("dist-deploy");
|
||||||
|
expect(parsed.flags.get("domain")).toBe("a.example.com");
|
||||||
|
expect(parsed.flags.get("json")).toBe(true);
|
||||||
|
});
|
||||||
|
test("help and version win, a flag without a value is an error, -- passes the rest through", () => {
|
||||||
|
expect(parseArgs([]).command).toBe("help");
|
||||||
|
expect(parseArgs(["build", "--help"]).command).toBe("help");
|
||||||
|
expect(parseArgs(["--version"]).command).toBe("version");
|
||||||
|
expect(() => parseArgs(["build", "--minify"])).toThrow(/--minify needs a value/);
|
||||||
|
expect(parseArgs(["smoke", "--", "--weird"]).positionals).toEqual(["--weird"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bunfigHasPlugin", () => {
|
||||||
|
test("accepts a relative path or package specifier that resolves to the m3 plugin", () => {
|
||||||
|
expect(bunfigHasPlugin(FIXTURE)).toBe(true);
|
||||||
|
expect(bunfigHasPlugin(tmp())).toBe(false);
|
||||||
|
});
|
||||||
|
test("rejects a bunfig without the plugin", async () => {
|
||||||
|
const dir = tmp();
|
||||||
|
await Bun.write(join(dir, "bunfig.toml"), '[serve.static]\nplugins = ["some-other-plugin"]\n');
|
||||||
|
expect(bunfigHasPlugin(dir)).toBe(false);
|
||||||
|
await Bun.write(join(dir, "bunfig.toml"), '[test]\npreload = ["x"]\n');
|
||||||
|
expect(bunfigHasPlugin(dir)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runCli", () => {
|
||||||
|
test("help, version and targets", async () => {
|
||||||
|
expect((await capture(() => runCli(["help"]))).text).toContain("Compile targets: linux-x64");
|
||||||
|
expect((await capture(() => runCli(["version"]))).text).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
const targets = await capture(() => runCli(["targets"]));
|
||||||
|
for (const id of [
|
||||||
|
"docker",
|
||||||
|
"systemd",
|
||||||
|
"nginx",
|
||||||
|
"vercel",
|
||||||
|
"fly",
|
||||||
|
"cloudflare",
|
||||||
|
"coolify",
|
||||||
|
"vscode",
|
||||||
|
"windows",
|
||||||
|
"linux-image",
|
||||||
|
])
|
||||||
|
expect(targets.text).toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("build prints the files and a summary; --json is the manifest", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const human = await capture(() => runCli(["build"], dir));
|
||||||
|
expect(human.code).toBe(0);
|
||||||
|
expect(human.text).toContain("m3-bun build hello-m3@1.2.3: 3 files");
|
||||||
|
expect(existsSync(join(dir, "dist", "index.html"))).toBe(true);
|
||||||
|
const machine = await capture(() => runCli(["build", "--json", "--minify", "none"], dir));
|
||||||
|
expect(JSON.parse(machine.text).manifest.minify).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deploy generates the requested targets into --out and says nothing was applied", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const out = tmp();
|
||||||
|
const { code, text } = await capture(() =>
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
"deploy",
|
||||||
|
"docker",
|
||||||
|
"systemd",
|
||||||
|
"--out",
|
||||||
|
out,
|
||||||
|
"--domain",
|
||||||
|
"x.example.org",
|
||||||
|
"--namespace",
|
||||||
|
"demo",
|
||||||
|
"--user",
|
||||||
|
"svc",
|
||||||
|
],
|
||||||
|
dir,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(text).toContain("generated only: nothing was applied, pushed or reloaded");
|
||||||
|
expect(readFileSync(join(out, "systemd", "hello-m3.service"), "utf8")).toContain(
|
||||||
|
"WorkingDirectory=/home/svc/apps/demo/current",
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(out, "systemd", "hello-m3.service"), "utf8")).toContain("User=svc");
|
||||||
|
expect(existsSync(join(out, "nginx"))).toBe(false);
|
||||||
|
const all = tmp();
|
||||||
|
await capture(() =>
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
"deploy",
|
||||||
|
"all",
|
||||||
|
"--out",
|
||||||
|
all,
|
||||||
|
"--http2",
|
||||||
|
"listen",
|
||||||
|
"--tls-cert",
|
||||||
|
"/c.pem",
|
||||||
|
"--tls-key",
|
||||||
|
"/k.pem",
|
||||||
|
],
|
||||||
|
dir,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tls = JSON.parse(readFileSync(join(all, "nginx", "hello-m3.nginx.json"), "utf8"))
|
||||||
|
.servers[1];
|
||||||
|
expect([tls.ssl_certificate, tls.listen_ports]).toEqual(["/c.pem", ["443 ssl http2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bad flag values are errors, not silent defaults", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await expect(runCli(["build", "--minify", "extreme"], dir)).rejects.toThrow(
|
||||||
|
/--minify must be none, basic or full/,
|
||||||
|
);
|
||||||
|
await expect(runCli(["compile", "--target", "plan9-x64"], dir)).rejects.toThrow(
|
||||||
|
/--target must be one of/,
|
||||||
|
);
|
||||||
|
await expect(runCli(["deploy", "docker", "--tls-cert", "/c.pem"], dir)).rejects.toThrow(
|
||||||
|
/go together/,
|
||||||
|
);
|
||||||
|
await expect(runCli(["deploy", "heroku"], dir)).rejects.toThrow(/unknown deploy target heroku/);
|
||||||
|
await expect(runCli(["serve", "--port", "-1"], dir)).rejects.toThrow(/non-negative integer/);
|
||||||
|
await expect(runCli(["package", "--format", "rar"], dir)).rejects.toThrow(
|
||||||
|
/--format must be tar.gz or zip/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoke needs a compiled executable and says which command makes one", async () => {
|
||||||
|
await expect(runCli(["smoke"], fixtureCopy())).rejects.toThrow(/m3-bun compile first/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { compileApp, defaultOutfile, renderEntry } from "../src/compile.ts";
|
||||||
|
import { loadApp } from "../src/config.ts";
|
||||||
|
import { formatSmoke, smoke } from "../src/runtime.ts";
|
||||||
|
import { cleanup, fixtureCopy, freePort } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("renderEntry", () => {
|
||||||
|
const entry = renderEntry({
|
||||||
|
runtime: "/pkg/src/entry.ts",
|
||||||
|
api: "/app/src/api.ts",
|
||||||
|
files: [
|
||||||
|
["/index.html", "/app/dist/index.html"],
|
||||||
|
["/chunk-a.js", "/app/dist/chunk-a.js"],
|
||||||
|
],
|
||||||
|
name: "demo",
|
||||||
|
version: "1.0.0",
|
||||||
|
buildId: "abc",
|
||||||
|
port: 3000,
|
||||||
|
csp: false,
|
||||||
|
});
|
||||||
|
test("embeds every file with the file loader and hands the table to runApp", () => {
|
||||||
|
expect(entry).toContain('import { runApp } from "/pkg/src/entry.ts";');
|
||||||
|
expect(entry).toContain('import api from "/app/src/api.ts";');
|
||||||
|
expect(entry).toContain('import file0 from "/app/dist/index.html" with { type: "file" };');
|
||||||
|
expect(entry).toContain('import file1 from "/app/dist/chunk-a.js" with { type: "file" };');
|
||||||
|
expect(entry).toContain('"/index.html": file0,');
|
||||||
|
expect(entry).toContain('"/chunk-a.js": file1,');
|
||||||
|
expect(entry).toContain("csp: false,");
|
||||||
|
expect(entry).toContain(" api,");
|
||||||
|
});
|
||||||
|
test("the entry has no top-level await: bytecode output is CommonJS", () => {
|
||||||
|
expect(entry).not.toMatch(/^await /m);
|
||||||
|
expect(entry).toContain("runApp({");
|
||||||
|
expect(entry).toContain("}).catch((error: unknown) => {");
|
||||||
|
// Parsing it as a CommonJS-compatible script must not trip on await.
|
||||||
|
const transpiled = new Bun.Transpiler({ loader: "ts" }).transformSync(entry);
|
||||||
|
expect(transpiled).not.toMatch(/^await /m);
|
||||||
|
});
|
||||||
|
test("without an API or a CSP those lines are absent; strings are JSON-escaped", () => {
|
||||||
|
const plain = renderEntry({
|
||||||
|
runtime: "/r.ts",
|
||||||
|
files: [],
|
||||||
|
name: 'we"ird',
|
||||||
|
version: "1",
|
||||||
|
buildId: "x",
|
||||||
|
port: 1,
|
||||||
|
});
|
||||||
|
expect(plain).not.toContain("import api");
|
||||||
|
expect(plain).not.toContain("csp:");
|
||||||
|
expect(plain).toContain('name: "we\\"ird"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultOutfile", () => {
|
||||||
|
test("bin/<name>, with the target in the name and .exe for Windows", async () => {
|
||||||
|
const app = await loadApp(fixtureCopy());
|
||||||
|
const hostSuffix = process.platform === "win32" ? ".exe" : "";
|
||||||
|
expect(defaultOutfile(app, undefined)).toBe(join(app.dir, "bin", `hello-m3${hostSuffix}`));
|
||||||
|
expect(defaultOutfile(app, "linux-x64-musl")).toBe(
|
||||||
|
join(app.dir, "bin", "hello-m3-linux-x64-musl"),
|
||||||
|
);
|
||||||
|
expect(defaultOutfile(app, "windows-x64")).toBe(
|
||||||
|
join(app.dir, "bin", "hello-m3-windows-x64.exe"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compileApp: one executable that serves the UI and the API", () => {
|
||||||
|
test("compiles the fixture, then the executable answers over HTTP", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const report = await compileApp({ dir });
|
||||||
|
expect(report.files).toBe(3);
|
||||||
|
expect(report.bytecode).toBe(true);
|
||||||
|
expect(existsSync(report.outfile)).toBe(true);
|
||||||
|
if (process.platform !== "win32") expect(statSync(report.outfile).mode & 0o111).not.toBe(0);
|
||||||
|
expect(report.bytes).toBeGreaterThan(10_000_000);
|
||||||
|
expect(readFileSync(report.entryFile, "utf8")).toContain(
|
||||||
|
"// Generated by @aphrody/m3-bun compile.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const version = Bun.spawnSync([report.outfile, "--version"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
});
|
||||||
|
expect(version.stdout.toString().trim()).toMatch(/^hello-m3 1\.2\.3 \([0-9a-f]{16}\)$/);
|
||||||
|
|
||||||
|
const port = freePort();
|
||||||
|
const result = await smoke(
|
||||||
|
report.outfile,
|
||||||
|
port,
|
||||||
|
["/", "/healthz", "/api/m3/info", "/api/hello/world"],
|
||||||
|
{
|
||||||
|
"/": "text/html",
|
||||||
|
"/healthz": "application/json",
|
||||||
|
"/api/m3/info": "application/json",
|
||||||
|
"/api/hello/world": "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.checks.map((c) => c.status)).toEqual([200, 200, 200, 200]);
|
||||||
|
expect(["buv-runtime", "bun-spawn"]).toContain(result.via);
|
||||||
|
expect(formatSmoke(result)).toContain("m3-bun smoke:");
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("--healthcheck exits 0 against a running instance and 1 against nothing", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const { outfile } = await compileApp({ dir });
|
||||||
|
const port = freePort();
|
||||||
|
const server = Bun.spawn([outfile], {
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
try {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Probe serially until the compiled server is ready, then stop polling.
|
||||||
|
await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Delay the next readiness probe only after a failed request.
|
||||||
|
await Bun.sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
Bun.spawnSync([outfile, "--healthcheck"], {
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
}).exitCode,
|
||||||
|
).toBe(0);
|
||||||
|
} finally {
|
||||||
|
server.kill();
|
||||||
|
await server.exited;
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
Bun.spawnSync([outfile, "--healthcheck"], {
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, PORT: String(freePort()) },
|
||||||
|
}).exitCode,
|
||||||
|
).toBe(1);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("compile.bytecode: false turns bytecode off, and the executable still runs", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "m3.config.ts"),
|
||||||
|
`import { defineM3Config } from "@aphrody/m3/config";
|
||||||
|
import { defineM3Bun } from "../../../src/config.ts";
|
||||||
|
export default defineM3Config({ entry: "src/index.html", theme: { seed: "#6750a4" } });
|
||||||
|
export const bun = defineM3Bun({ api: "src/api.ts", compile: { bytecode: false } });
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const plain = await compileApp({ dir });
|
||||||
|
expect(plain.bytecode).toBe(false);
|
||||||
|
const withBytecode = await compileApp({ dir: fixtureCopy() });
|
||||||
|
expect(withBytecode.bytecode).toBe(true);
|
||||||
|
expect(withBytecode.bytes).toBeGreaterThan(plain.bytes);
|
||||||
|
expect(
|
||||||
|
Bun.spawnSync([plain.outfile, "--version"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
}).stdout.toString(),
|
||||||
|
).toContain("hello-m3 1.2.3");
|
||||||
|
expect(
|
||||||
|
Bun.spawnSync([withBytecode.outfile, "--version"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
}).stdout.toString(),
|
||||||
|
).toContain("hello-m3 1.2.3");
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("compiling without a build and without --no-build output is an error that says what to run", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await expect(compileApp({ dir, build: false })).rejects.toThrow(/no build in .*m3-bun build/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
COMPILE_TARGETS,
|
||||||
|
compileTarget,
|
||||||
|
defineM3Bun,
|
||||||
|
findWorkspaceRoot,
|
||||||
|
loadApp,
|
||||||
|
} from "../src/config.ts";
|
||||||
|
import { slug } from "../src/util.ts";
|
||||||
|
import { cleanup, fixtureCopy, FIXTURE } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("loadApp", () => {
|
||||||
|
test("derives name, version, port, paths and the minify default from the fixture", async () => {
|
||||||
|
const app = await loadApp(FIXTURE);
|
||||||
|
expect(app.name).toBe("hello-m3");
|
||||||
|
expect(app.version).toBe("1.2.3");
|
||||||
|
expect(app.port).toBe(3456);
|
||||||
|
expect(app.api).toBe(join(FIXTURE, "src/api.ts"));
|
||||||
|
expect(app.outdir).toBe(join(FIXTURE, "dist"));
|
||||||
|
expect(app.minify).toBe("full");
|
||||||
|
expect(app.m3.entry).toBe(join(FIXTURE, "src/index.html"));
|
||||||
|
expect(app.bun.deploy?.domain).toBe("hello.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an app without a `bun` export still loads, with defaults", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "m3.config.ts"),
|
||||||
|
'import { defineM3Config } from "@aphrody/m3/config";\nexport default defineM3Config({ entry: "src/index.html", port: 4001 });\n',
|
||||||
|
);
|
||||||
|
const app = await loadApp(dir);
|
||||||
|
expect(app.api).toBeUndefined();
|
||||||
|
expect(app.port).toBe(4001);
|
||||||
|
expect(app.bun).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bad minify level and a missing api module are errors", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "m3.config.ts"),
|
||||||
|
'import { defineM3Config } from "@aphrody/m3/config";\nexport default defineM3Config({ entry: "src/index.html" });\nexport const bun = { minify: "extreme" };\n',
|
||||||
|
);
|
||||||
|
await expect(loadApp(dir)).rejects.toThrow(/minify must be none, basic or full/);
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "m3.config.ts"),
|
||||||
|
'import { defineM3Config } from "@aphrody/m3/config";\nexport default defineM3Config({ entry: "src/index.html" });\nexport const bun = { api: "src/nope.ts" };\n',
|
||||||
|
);
|
||||||
|
await expect(loadApp(dir)).rejects.toThrow(/api module not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an embedded module uses its product identity without another package manifest", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "m3.config.ts"),
|
||||||
|
'import { defineM3Config } from "@aphrody/m3/config";\nexport default defineM3Config({ entry: "src/index.html" });\nexport const bun = { name: "embedded-module", version: "4.5.6" };\n',
|
||||||
|
);
|
||||||
|
const app = await loadApp(dir);
|
||||||
|
expect(app.name).toBe("embedded-module");
|
||||||
|
expect(app.version).toBe("4.5.6");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("helpers", () => {
|
||||||
|
test("slug makes DNS-safe names", () => {
|
||||||
|
expect(slug("@aphrody/m3-showcase")).toBe("m3-showcase");
|
||||||
|
expect(slug("My App_2!")).toBe("my-app-2");
|
||||||
|
expect(slug("___")).toBe("app");
|
||||||
|
});
|
||||||
|
test("compile targets map to bun-<name>; unknown names throw", () => {
|
||||||
|
expect(compileTarget("linux-x64-musl")).toBe("bun-linux-x64-musl");
|
||||||
|
expect(compileTarget(undefined)).toBeUndefined();
|
||||||
|
expect(() => compileTarget("plan9-x64" as never)).toThrow(/unknown compile target/);
|
||||||
|
expect(COMPILE_TARGETS).toContain("windows-x64");
|
||||||
|
});
|
||||||
|
test("defineM3Bun is the identity and findWorkspaceRoot finds the monorepo", () => {
|
||||||
|
const config = { port: 1 };
|
||||||
|
expect(defineM3Bun(config)).toBe(config);
|
||||||
|
expect(findWorkspaceRoot(FIXTURE)).toBe(join(import.meta.dir, "../../../.."));
|
||||||
|
expect(findWorkspaceRoot("/")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
launchInspected,
|
||||||
|
serveInspectorFrontend,
|
||||||
|
type M3ConsoleEntry,
|
||||||
|
type M3InspectedException,
|
||||||
|
} from "../src/dev-inspect.ts";
|
||||||
|
import { failingApi, loadDevApi } from "../src/dev-error.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const resolvable = (specifier: string): boolean => {
|
||||||
|
try {
|
||||||
|
Bun.resolveSync(specifier, import.meta.dir);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hasInspector =
|
||||||
|
resolvable("@aphrody/bun-inspector-protocol") &&
|
||||||
|
resolvable("@aphrody/bun-debug-adapter-protocol");
|
||||||
|
|
||||||
|
const src = (file: string) => JSON.stringify(join(import.meta.dir, "..", "src", file));
|
||||||
|
|
||||||
|
describe.skipIf(!hasInspector)(
|
||||||
|
"m3-bun dev --inspect (bun-inspector-protocol, bun-debug-adapter-protocol)",
|
||||||
|
() => {
|
||||||
|
test("the child's console and its uncaught exception are relayed through the inspector", async () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const program = join(dir, "server.ts");
|
||||||
|
writeFileSync(
|
||||||
|
program,
|
||||||
|
[
|
||||||
|
'console.log("ready", { port: 0 }, [1, 2]);',
|
||||||
|
'console.warn("careful");',
|
||||||
|
'setTimeout(() => { throw new TypeError("kaboom"); }, 1);',
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
const entries: M3ConsoleEntry[] = [];
|
||||||
|
const thrown = Promise.withResolvers<M3InspectedException>();
|
||||||
|
const inspected = await launchInspected({
|
||||||
|
cmd: [process.execPath, program],
|
||||||
|
cwd: dir,
|
||||||
|
stdio: "ignore",
|
||||||
|
onConsole: (entry) => void entries.push(entry),
|
||||||
|
onException: (exception) => thrown.resolve(exception),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(inspected.inspectorUrl).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/\w+$/);
|
||||||
|
await inspected.attached;
|
||||||
|
const exception = await thrown.promise;
|
||||||
|
expect(exception.name).toBe("TypeError");
|
||||||
|
expect(exception.message).toBe("kaboom");
|
||||||
|
expect(exception.frames[0]).toMatch(/server\.ts:\d+:\d+$/);
|
||||||
|
expect(entries.slice(0, 2)).toEqual([
|
||||||
|
{ level: "log", text: expect.stringMatching(/^ready .*port.*0.*1.*2/) },
|
||||||
|
{ level: "warning", text: "careful" },
|
||||||
|
]);
|
||||||
|
expect(await inspected.child.exited).not.toBe(0);
|
||||||
|
} finally {
|
||||||
|
await inspected.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe.skipIf(!resolvable("@aphrody/web-inspector-bun"))(
|
||||||
|
"Web Inspector frontend (@aphrody/web-inspector-bun)",
|
||||||
|
() => {
|
||||||
|
test("the WebKit Web Inspector is served on a free port, pointed at the inspector", async () => {
|
||||||
|
const frontend = serveInspectorFrontend({ ws: "ws://127.0.0.1:6499/abc" });
|
||||||
|
try {
|
||||||
|
const url = new URL(frontend.url);
|
||||||
|
expect(url.hostname).toBe("127.0.0.1");
|
||||||
|
expect(url.pathname).toBe("/index.html");
|
||||||
|
expect(url.searchParams.get("ws")).toBe("127.0.0.1:6499/abc");
|
||||||
|
const page = await fetch(frontend.url);
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-security-policy")).toBeNull();
|
||||||
|
expect(await page.text()).toContain("<!DOCTYPE html>");
|
||||||
|
const host = await fetch(new URL("/Base/BrowserInspectorFrontendHost.js", url));
|
||||||
|
expect(await host.text()).toContain('"ws" in queryParams');
|
||||||
|
} finally {
|
||||||
|
await frontend.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("API errors in dev go to Bun's error overlay (bun-error)", () => {
|
||||||
|
test("an API module that fails to load: the dev server starts and its routes render the overlay", async () => {
|
||||||
|
// In a child: inside `bun test`, an error that a dev server reports also fails the running test.
|
||||||
|
const dir = tmp();
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "api.ts"),
|
||||||
|
'throw new Error("api.ts: database url missing");\nexport default {};',
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, "index.html"), "<!doctype html><html><body>ui</body></html>");
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "dev.ts"),
|
||||||
|
`import { loadDevApi } from ${src("dev-error.ts")};
|
||||||
|
import { startM3Server } from ${src("server.ts")};
|
||||||
|
import index from "./index.html";
|
||||||
|
const api = await loadDevApi(${JSON.stringify(join(dir, "api.ts"))});
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", index, api, hmr: false });
|
||||||
|
const ui = await (await fetch(server.url)).text();
|
||||||
|
const res = await fetch(new URL("/api/notes", server.url));
|
||||||
|
console.log(JSON.stringify({ ui, status: res.status, overlay: await res.text() }));
|
||||||
|
await server.stop(true);
|
||||||
|
process.exit(0); // a reported handler error would otherwise set exit code 1
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await using child = Bun.spawn([process.execPath, "dev.ts"], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: dir,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
child.exited,
|
||||||
|
]);
|
||||||
|
const result = JSON.parse(stdout.trim().split("\n").at(-1)!) as {
|
||||||
|
ui: string;
|
||||||
|
status: number;
|
||||||
|
overlay: string;
|
||||||
|
};
|
||||||
|
expect(result.ui).toContain("ui");
|
||||||
|
expect(result.status).toBe(500);
|
||||||
|
expect(result.overlay).toContain("__bunfallback");
|
||||||
|
expect(result.overlay).toContain("Bun__renderFallbackError");
|
||||||
|
expect(stderr).toContain("api.ts: database url missing");
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a module that loads is returned as is; failingApi covers the prefix", async () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const ok = join(dir, "api.ts");
|
||||||
|
writeFileSync(ok, 'export default { "/api/a": () => new Response("a") };');
|
||||||
|
expect(Object.keys((await loadDevApi(ok)) as object)).toEqual(["/api/a"]);
|
||||||
|
const api = failingApi(new Error("x"), "/rpc/") as { routes: Record<string, () => never> };
|
||||||
|
expect(Object.keys(api.routes)).toEqual(["/rpc", "/rpc/*"]);
|
||||||
|
expect(() => api.routes["/rpc"]!()).toThrow("x");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { flagValue, runApp } from "../src/entry.ts";
|
||||||
|
|
||||||
|
describe("flagValue", () => {
|
||||||
|
test("--name value and --name=value", () => {
|
||||||
|
expect(flagValue(["--port", "8080"], "port")).toBe("8080");
|
||||||
|
expect(flagValue(["--port=9090", "--x"], "port")).toBe("9090");
|
||||||
|
expect(flagValue(["--x"], "port")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runApp command line", () => {
|
||||||
|
const base = { name: "demo", version: "1.0.0", buildId: "abc", port: 3000, files: {} };
|
||||||
|
const capture = async (argv: string[]) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const original = console.log;
|
||||||
|
console.log = (...args: unknown[]) => void lines.push(args.join(" "));
|
||||||
|
try {
|
||||||
|
await runApp(base, argv);
|
||||||
|
} finally {
|
||||||
|
console.log = original;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("--version prints name, version and build id and returns", async () => {
|
||||||
|
expect(await capture(["--version"])).toEqual(["demo 1.0.0 (abc)"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--healthcheck sets a failing exit code when nothing listens, and rejects a bad port", async () => {
|
||||||
|
const saved = process.exitCode;
|
||||||
|
const savedPort = Bun.env.PORT;
|
||||||
|
try {
|
||||||
|
Bun.env.PORT = "1";
|
||||||
|
await runApp(base, ["--healthcheck"]);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
Bun.env.PORT = "70000";
|
||||||
|
await expect(runApp(base, [])).rejects.toThrow(/invalid port/);
|
||||||
|
} finally {
|
||||||
|
// Bun ignores an undefined assignment after an exit code has been set.
|
||||||
|
process.exitCode = saved ?? 0;
|
||||||
|
if (savedPort === undefined) delete Bun.env.PORT;
|
||||||
|
else Bun.env.PORT = savedPort;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
[serve.static]
|
||||||
|
plugins = ["../../../src/plugin.ts"]
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineM3Config } from "@aphrody/m3/config";
|
||||||
|
import { defineM3Bun } from "../../../src/config.ts";
|
||||||
|
|
||||||
|
export default defineM3Config({
|
||||||
|
entry: "src/index.html",
|
||||||
|
theme: { seed: "#6750a4" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bun = defineM3Bun({
|
||||||
|
api: "src/api.ts",
|
||||||
|
port: 3456,
|
||||||
|
deploy: { domain: "hello.example.com", namespace: "aphrody" },
|
||||||
|
});
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@fixture/hello-m3",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineM3Api } from "../../../../src/api.ts";
|
||||||
|
|
||||||
|
export default defineM3Api({
|
||||||
|
"/api/hello/:name": (request) => Response.json({ hello: request.params.name }),
|
||||||
|
"/api/items": {
|
||||||
|
GET: () => Response.json([{ id: 1 }]),
|
||||||
|
POST: async (request) => Response.json(await request.json(), { status: 201 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "m3:icons/*" {
|
||||||
|
const svg: string;
|
||||||
|
export default svg;
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Hello M3</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app">Hello M3</main>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
import home from "m3:icons/home";
|
||||||
|
|
||||||
|
const app = document.getElementById("app");
|
||||||
|
if (app) app.insertAdjacentHTML("afterbegin", home);
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
@import "m3:theme.css";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
font: 400 1rem/1.5 system-ui, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, symlinkSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { writeFiles } from "../src/util.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
test("generated deployment files remain within the destination, including symlink boundaries", async () => {
|
||||||
|
const root = tmp();
|
||||||
|
const outside = tmp();
|
||||||
|
await expect(writeFiles(root, [{ path: "../escape", content: "bad" }])).rejects.toThrow(
|
||||||
|
"escapes",
|
||||||
|
);
|
||||||
|
expect(await writeFiles(root, [{ path: "nested/service.conf", content: "safe" }])).toEqual([
|
||||||
|
join(root, "nested/service.conf"),
|
||||||
|
]);
|
||||||
|
expect(await Bun.file(join(root, "nested/service.conf")).text()).toBe("safe");
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
mkdirSync(join(root, "deploy"));
|
||||||
|
symlinkSync(outside, join(root, "deploy/external"), "dir");
|
||||||
|
await expect(
|
||||||
|
writeFiles(root, [{ path: "deploy/external/private", content: "bad" }]),
|
||||||
|
).rejects.toThrow("symlink");
|
||||||
|
expect(await Bun.file(join(outside, "private")).exists()).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { buildApp } from "../src/build.ts";
|
||||||
|
import { dependents, graphReport, moduleGraph, openGraph } from "../src/graph.ts";
|
||||||
|
import { cleanup, fixtureCopy, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("moduleGraph", () => {
|
||||||
|
test("inputs, outputs, imports and bundled-into links of a Bun.build metafile", async () => {
|
||||||
|
const dir = tmp();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "index.html"),
|
||||||
|
'<link rel="stylesheet" href="./s.css"><script type="module" src="./a.ts"></script>',
|
||||||
|
);
|
||||||
|
await Bun.write(join(dir, "a.ts"), 'import { b } from "./b.ts"; import("./c.ts"); console.log(b);');
|
||||||
|
await Bun.write(join(dir, "b.ts"), "export const b = 1;");
|
||||||
|
await Bun.write(join(dir, "c.ts"), "export const c = 1;");
|
||||||
|
await Bun.write(join(dir, "s.css"), "body { color: red }");
|
||||||
|
const { metafile } = await Bun.build({
|
||||||
|
entrypoints: [join(dir, "index.html")],
|
||||||
|
root: dir,
|
||||||
|
outdir: join(dir, "out"),
|
||||||
|
splitting: true,
|
||||||
|
metafile: true,
|
||||||
|
});
|
||||||
|
const graph = moduleGraph(metafile!, "index.html", dir);
|
||||||
|
const ids = new Set(graph.nodes.map(n => n.id));
|
||||||
|
expect(ids.size).toBe(graph.nodes.length);
|
||||||
|
expect([...ids].filter(id => id.startsWith("input:")).toSorted()).toEqual([
|
||||||
|
"input:a.ts",
|
||||||
|
"input:b.ts",
|
||||||
|
"input:c.ts",
|
||||||
|
"input:index.html",
|
||||||
|
"input:s.css",
|
||||||
|
]);
|
||||||
|
expect(ids.has("output:index.html")).toBe(true);
|
||||||
|
for (const link of graph.links) {
|
||||||
|
expect(ids.has(link.source)).toBe(true);
|
||||||
|
expect(ids.has(link.target)).toBe(true);
|
||||||
|
}
|
||||||
|
const relations = graph.links.map(
|
||||||
|
l => `${l.source} ${l.relation} ${l.target.startsWith("output:") ? "output" : l.target}`,
|
||||||
|
);
|
||||||
|
expect(relations).toContain("input:a.ts import-statement input:b.ts");
|
||||||
|
expect(relations).toContain("input:index.html import-rule input:s.css");
|
||||||
|
expect(relations).toContain("input:a.ts dynamic-import output");
|
||||||
|
expect(relations).toContain("input:b.ts bundled-into output");
|
||||||
|
expect(relations).toContain("input:s.css bundled-into output");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasGraph = true;
|
||||||
|
try {
|
||||||
|
await import("bun:graph");
|
||||||
|
} catch {
|
||||||
|
hasGraph = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasGraph)("m3-bun build --graph (bun:graph)", () => {
|
||||||
|
test("a build is a run with its artifacts and duration, and the module graph a snapshot", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const report = await buildApp({ dir, graph: true });
|
||||||
|
const record = report.graph!;
|
||||||
|
expect(record.path).toBe(join(dir, ".m3-bun", "graph.sqlite"));
|
||||||
|
expect(record.repositoryId).toBe("m3-app:hello-m3");
|
||||||
|
using registry = await openGraph(record.path);
|
||||||
|
expect(registry.queryRuns({ kind: "m3-bun.build" }).map(run => run.status)).toEqual(["passed"]);
|
||||||
|
const artifacts = registry.db
|
||||||
|
.query<
|
||||||
|
{ sha256: string; bytes: number; metadata: string },
|
||||||
|
[string]
|
||||||
|
>("SELECT sha256, bytes, metadata FROM artifacts WHERE run_id = ? ORDER BY path")
|
||||||
|
.all(record.runId);
|
||||||
|
expect(artifacts.map(a => ({ path: JSON.parse(a.metadata).path, sha256: a.sha256, bytes: a.bytes }))).toEqual(
|
||||||
|
report.manifest.files
|
||||||
|
.map(f => ({ path: f.path, sha256: f.sha256, bytes: f.bytes }))
|
||||||
|
.toSorted((a, b) => a.path.localeCompare(b.path)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registry.db
|
||||||
|
.query<{ milliseconds: number }, [string]>("SELECT milliseconds FROM samples WHERE run_id = ?")
|
||||||
|
.get(record.runId)!.milliseconds,
|
||||||
|
).toBe(report.ms);
|
||||||
|
const coverage = registry.coverage({ repositoryId: record.repositoryId });
|
||||||
|
expect(coverage.snapshot!.id).toBe(record.snapshotId);
|
||||||
|
expect({ nodes: coverage.nodes, edges: coverage.edges, unresolved: coverage.unresolvedNodes }).toEqual({
|
||||||
|
nodes: record.nodes,
|
||||||
|
edges: record.edges,
|
||||||
|
unresolved: 0,
|
||||||
|
});
|
||||||
|
const labels = registry.queryNodes({ repositoryId: record.repositoryId, limit: 500 }).map(n => n.label);
|
||||||
|
expect(labels).toContain("src/main.ts");
|
||||||
|
expect(labels).toContain("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3-bun graph <file> lists what depends on it; the report names the app", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const { app } = await buildApp({ dir, graph: true });
|
||||||
|
const found = await dependents(app, "src/main.ts");
|
||||||
|
expect(found).toContainEqual({ label: "src/index.html", kind: "file", depth: 1 });
|
||||||
|
expect(found.some(n => n.kind === "output" && n.label.endsWith(".js"))).toBe(true);
|
||||||
|
expect(await graphReport(app)).toContain("m3-app:hello-m3");
|
||||||
|
expect(dependents(app, "src/none.ts")).rejects.toThrow("is not an input");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { cpSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export const FIXTURE = join(import.meta.dir, "fixtures/app");
|
||||||
|
|
||||||
|
const made: string[] = [];
|
||||||
|
|
||||||
|
/** A throwaway directory, removed by `cleanup()` (call it in `afterAll`). */
|
||||||
|
export function tmp(prefix = "m3-bun-test-"): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||||
|
made.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A copy of the fixture app inside the repository (so workspace packages resolve), removed by `cleanup()`. */
|
||||||
|
export function fixtureCopy(): string {
|
||||||
|
const dir = mkdtempSync(join(import.meta.dir, "fixtures", ".copy-"));
|
||||||
|
cpSync(FIXTURE, dir, { recursive: true, filter: (src) => !/\/(dist|bin|release|node_modules|\.copy-[^/]*)(\/|$)/.test(src) });
|
||||||
|
made.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup(): void {
|
||||||
|
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A port that is free right now. */
|
||||||
|
export function freePort(): number {
|
||||||
|
const server = Bun.serve({ port: 0, fetch: () => new Response("") });
|
||||||
|
const { port } = server;
|
||||||
|
void server.stop(true);
|
||||||
|
return port as number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { routesToFetch } from "../src/api.ts";
|
||||||
|
import { pickEncoding, resolvePort } from "../src/server.ts";
|
||||||
|
|
||||||
|
test("compression respects exclusions, quality order and wildcard acceptance", () => {
|
||||||
|
expect(pickEncoding("br;q=0, gzip;q=1")).toBe("gzip");
|
||||||
|
expect(pickEncoding("gzip;q=0, br;q=0")).toBeUndefined();
|
||||||
|
expect(pickEncoding("br;q=0.3, gzip;q=0.8")).toBe("gzip");
|
||||||
|
expect(pickEncoding("br;q=0.8, gzip;q=0.3")).toBe("br");
|
||||||
|
expect(pickEncoding("*;q=0.5, br;q=0")).toBe("gzip");
|
||||||
|
expect(pickEncoding("br;q=invalid, gzip;q=2")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("route precedence favors the first static segment over deeper parameter routes", async () => {
|
||||||
|
const handler = routesToFetch({
|
||||||
|
"/api/:id/settings": () => new Response("parameter"),
|
||||||
|
"/api/admin/:page": () => new Response("static"),
|
||||||
|
"/api/*": () => new Response("wildcard"),
|
||||||
|
});
|
||||||
|
expect(await (await handler(new Request("http://m3.test/api/admin/settings")))!.text()).toBe(
|
||||||
|
"static",
|
||||||
|
);
|
||||||
|
expect(await (await handler(new Request("http://m3.test/api/a/settings")))!.text()).toBe(
|
||||||
|
"parameter",
|
||||||
|
);
|
||||||
|
expect(await (await handler(new Request("http://m3.test/api/other/page")))!.text()).toBe(
|
||||||
|
"wildcard",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed parameters return 400 and HEAD has no response body", async () => {
|
||||||
|
const handler = routesToFetch({
|
||||||
|
"/api/:id": () => new Response("body", { headers: { "x-contract": "kept" } }),
|
||||||
|
});
|
||||||
|
expect((await handler(new Request("http://m3.test/api/%ZZ")))!.status).toBe(400);
|
||||||
|
const head = await handler(new Request("http://m3.test/api/item", { method: "HEAD" }));
|
||||||
|
expect(head!.headers.get("x-contract")).toBe("kept");
|
||||||
|
expect(await head!.text()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3-bun ignores invalid environment ports and validates explicit ports", () => {
|
||||||
|
const saved = Bun.env.PORT;
|
||||||
|
try {
|
||||||
|
for (const invalid of ["", " ", "65536", "-1", "1.5"]) {
|
||||||
|
Bun.env.PORT = invalid;
|
||||||
|
expect(resolvePort(4000)).toBe(4000);
|
||||||
|
}
|
||||||
|
expect(() => resolvePort(65536)).toThrow("invalid port");
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete Bun.env.PORT;
|
||||||
|
else Bun.env.PORT = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
iconCacheFile,
|
||||||
|
iconFile,
|
||||||
|
iconUrl,
|
||||||
|
iconVariant,
|
||||||
|
iconsRoot,
|
||||||
|
loadIcon,
|
||||||
|
parseIconSpecifier,
|
||||||
|
readIcon,
|
||||||
|
} from "../src/icons.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("parseIconSpecifier", () => {
|
||||||
|
test("name, style and variant query", () => {
|
||||||
|
expect(parseIconSpecifier("home")).toEqual({
|
||||||
|
name: "home",
|
||||||
|
style: "outlined",
|
||||||
|
fill: false,
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(parseIconSpecifier("rounded/arrow_back?fill=1&wght=300")).toEqual({
|
||||||
|
name: "arrow_back",
|
||||||
|
style: "rounded",
|
||||||
|
fill: true,
|
||||||
|
weight: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("bad names, styles and weights are errors", () => {
|
||||||
|
expect(() => parseIconSpecifier("Home")).toThrow(/invalid icon name/);
|
||||||
|
expect(() => parseIconSpecifier("../x")).toThrow(/invalid icon name|style must be/);
|
||||||
|
expect(() => parseIconSpecifier("fancy/home")).toThrow(/style must be one of/);
|
||||||
|
expect(() => parseIconSpecifier("home?wght=450")).toThrow(/wght must be/);
|
||||||
|
});
|
||||||
|
test("file names follow the catalogue spelling", () => {
|
||||||
|
expect(iconFile({ name: "home", style: "outlined", fill: false, weight: 400 })).toBe(
|
||||||
|
"home_24px.svg",
|
||||||
|
);
|
||||||
|
expect(iconFile({ name: "home", style: "outlined", fill: true, weight: 400 })).toBe(
|
||||||
|
"home_fill1_24px.svg",
|
||||||
|
);
|
||||||
|
expect(iconFile({ name: "home", style: "outlined", fill: false, weight: 300 })).toBe(
|
||||||
|
"home_wght300_24px.svg",
|
||||||
|
);
|
||||||
|
expect(iconFile({ name: "home", style: "outlined", fill: true, weight: 300 })).toBe(
|
||||||
|
"home_wght300fill1_24px.svg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readIcon", () => {
|
||||||
|
const root = iconsRoot() ?? null;
|
||||||
|
test.skipIf(!root)("reads real SVG markup from the forked catalogue", () => {
|
||||||
|
const svg = readIcon({ name: "home", style: "outlined", fill: false, weight: 400 }, root);
|
||||||
|
expect(svg).toStartWith("<svg");
|
||||||
|
expect(svg).toContain('viewBox="0 -960 960 960"');
|
||||||
|
expect(readIcon({ name: "home", style: "rounded", fill: true, weight: 400 }, root)).not.toBe(
|
||||||
|
svg,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
readIcon({ name: "no_such_symbol_xyz", style: "outlined", fill: false, weight: 400 }, root),
|
||||||
|
).toThrow(/no icon outlined\/no_such_symbol_xyz_24px\.svg/);
|
||||||
|
});
|
||||||
|
test("without a catalogue readIcon points to loadIcon", () => {
|
||||||
|
expect(() =>
|
||||||
|
readIcon({ name: "home", style: "outlined", fill: false, weight: 400 }, null),
|
||||||
|
).toThrow(/use loadIcon/);
|
||||||
|
});
|
||||||
|
test("CDN variants follow the gstatic layout", () => {
|
||||||
|
expect(iconVariant({ name: "home", style: "outlined", fill: false, weight: 400 })).toBe(
|
||||||
|
"default",
|
||||||
|
);
|
||||||
|
expect(iconVariant({ name: "home", style: "outlined", fill: true, weight: 300 })).toBe(
|
||||||
|
"wght300fill1",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
iconUrl(
|
||||||
|
{ name: "home", style: "rounded", fill: true, weight: 400 },
|
||||||
|
"https://cdn.aphrody.com/",
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"https://cdn.aphrody.com/s/i/short-term/release/materialsymbolsrounded/home/fill1/24px.svg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("loadIcon fetches from the icon origin once, then reads its disk cache", async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const { pathname } = new URL(request.url);
|
||||||
|
requests.push(pathname);
|
||||||
|
if (pathname.includes("/no_such_symbol_xyz/")) return new Response("missing", { status: 404 });
|
||||||
|
return new Response(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><title>${pathname}</title></svg>`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const saved = { cache: Bun.env.M3_ICONS_CACHE, origin: Bun.env.M3_ICONS_ORIGIN };
|
||||||
|
const cache = tmp("m3-icons-");
|
||||||
|
Bun.env.M3_ICONS_CACHE = cache;
|
||||||
|
Bun.env.M3_ICONS_ORIGIN = server.url.href;
|
||||||
|
try {
|
||||||
|
const home = { name: "home", style: "outlined", fill: false, weight: 400 } as const;
|
||||||
|
const svg = await loadIcon(home, null);
|
||||||
|
expect(svg).toBe(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><title>/s/i/short-term/release/materialsymbolsoutlined/home/default/24px.svg</title></svg>`,
|
||||||
|
);
|
||||||
|
const file = iconCacheFile(home);
|
||||||
|
expect(file).toStartWith(`${cache}/`);
|
||||||
|
expect(file).toEndWith("/outlined/home/default.svg");
|
||||||
|
expect(iconCacheFile(home, "https://other.example")).not.toBe(file);
|
||||||
|
expect(await Bun.file(file).text()).toBe(svg);
|
||||||
|
expect(await loadIcon(home, null)).toBe(svg);
|
||||||
|
expect(await loadIcon({ name: "home", style: "rounded", fill: true, weight: 400 }, null)).not.toBe(svg);
|
||||||
|
await expect(
|
||||||
|
loadIcon({ name: "no_such_symbol_xyz", style: "outlined", fill: false, weight: 400 }, null),
|
||||||
|
).rejects.toThrow(/no icon outlined\/no_such_symbol_xyz_24px\.svg/);
|
||||||
|
expect(requests).toEqual([
|
||||||
|
"/s/i/short-term/release/materialsymbolsoutlined/home/default/24px.svg",
|
||||||
|
"/s/i/short-term/release/materialsymbolsrounded/home/fill1/24px.svg",
|
||||||
|
"/s/i/short-term/release/materialsymbolsoutlined/no_such_symbol_xyz/default/24px.svg",
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of [["M3_ICONS_CACHE", saved.cache], ["M3_ICONS_ORIGIN", saved.origin]] as const) {
|
||||||
|
if (value === undefined) delete Bun.env[key];
|
||||||
|
else Bun.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("M3_ICONS_ROOT wins", () => {
|
||||||
|
const saved = Bun.env.M3_ICONS_ROOT;
|
||||||
|
Bun.env.M3_ICONS_ROOT = "/somewhere/else";
|
||||||
|
try {
|
||||||
|
expect(iconsRoot()).toBe(resolve("/somewhere/else"));
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete Bun.env.M3_ICONS_ROOT;
|
||||||
|
else Bun.env.M3_ICONS_ROOT = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
||||||
|
import { basename, dirname, join, relative } from "node:path";
|
||||||
|
import {
|
||||||
|
archiveDir,
|
||||||
|
bunArchiveSupported,
|
||||||
|
buildRootfs,
|
||||||
|
packageApp,
|
||||||
|
parseLdd,
|
||||||
|
} from "../src/package.ts";
|
||||||
|
import { sha256 } from "../src/util.ts";
|
||||||
|
import { cleanup, fixtureCopy, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const archiveSupported = await bunArchiveSupported();
|
||||||
|
const archiver = async () => ((await bunArchiveSupported()) ? "bun-archive" : "m3-bun");
|
||||||
|
|
||||||
|
const LDD = `\tlinux-vdso.so.1 (0x00007e85dea9f000)
|
||||||
|
\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007e85de800000)
|
||||||
|
\t/lib64/ld-linux-x86-64.so.2 (0x00007e85deaa1000)
|
||||||
|
\tlibpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007e85dea74000)
|
||||||
|
\tlibmissing.so.1 => not found
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("parseLdd", () => {
|
||||||
|
test("absolute library paths and the loader; the vdso and unresolved names are skipped; sorted and unique", () => {
|
||||||
|
expect(parseLdd(LDD)).toEqual([
|
||||||
|
"/lib/x86_64-linux-gnu/libc.so.6",
|
||||||
|
"/lib/x86_64-linux-gnu/libpthread.so.0",
|
||||||
|
"/lib64/ld-linux-x86-64.so.2",
|
||||||
|
]);
|
||||||
|
expect(parseLdd(`${LDD}${LDD}`)).toHaveLength(3);
|
||||||
|
expect(parseLdd("")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archiveDir", () => {
|
||||||
|
test("tar.gz keeps the executable bit and lists every staged file", async () => {
|
||||||
|
const staging = tmp();
|
||||||
|
writeFileSync(join(staging, "app"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(staging, "README.txt"), "hi");
|
||||||
|
mkdirSync(join(staging, "deploy"));
|
||||||
|
writeFileSync(join(staging, "deploy", "x.conf"), "x");
|
||||||
|
const archive = join(tmp(), "out", "a.tar.gz");
|
||||||
|
expect(await archiveDir(staging, archive, "tar.gz")).toBe(await archiver());
|
||||||
|
const listing = Bun.spawnSync(["tar", "-tvzf", basename(archive)], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: dirname(archive),
|
||||||
|
stdout: "pipe",
|
||||||
|
})
|
||||||
|
.stdout.toString()
|
||||||
|
.replaceAll("\r\n", "\n");
|
||||||
|
expect(listing).toMatch(/-rwxr-xr-x .* (\.\/)?app\n/);
|
||||||
|
expect(listing).toMatch(/ (\.\/)?deploy\/x\.conf\n/);
|
||||||
|
expect(listing).toMatch(/ (\.\/)?README\.txt\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the tarball is byte-reproducible", async () => {
|
||||||
|
const staging = tmp();
|
||||||
|
writeFileSync(join(staging, "a"), "a");
|
||||||
|
writeFileSync(join(staging, "b"), "b");
|
||||||
|
const out = tmp();
|
||||||
|
await archiveDir(staging, join(out, "1.tar.gz"), "tar.gz");
|
||||||
|
utimesSync(join(staging, "a"), new Date(2001, 0, 1), new Date(2001, 0, 1));
|
||||||
|
await archiveDir(staging, join(out, "2.tar.gz"), "tar.gz");
|
||||||
|
expect(sha256(new Uint8Array(readFileSync(join(out, "1.tar.gz"))))).toBe(
|
||||||
|
sha256(new Uint8Array(readFileSync(join(out, "2.tar.gz")))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!Bun.which("unzip"))("zip archives", async () => {
|
||||||
|
const staging = tmp();
|
||||||
|
writeFileSync(join(staging, "app.exe"), "MZ");
|
||||||
|
mkdirSync(join(staging, "deploy"));
|
||||||
|
writeFileSync(join(staging, "deploy", "x.conf"), Buffer.alloc(4096, "x").toString());
|
||||||
|
const archive = join(tmp(), "a.zip");
|
||||||
|
expect(await archiveDir(staging, archive, "zip")).toBe(await archiver());
|
||||||
|
const check = Bun.spawnSync(["unzip", "-t", basename(archive)], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: dirname(archive),
|
||||||
|
stdout: "pipe",
|
||||||
|
});
|
||||||
|
expect(check.stdout.toString()).toContain("app.exe");
|
||||||
|
expect(check.stdout.toString()).toContain("deploy/x.conf");
|
||||||
|
expect(check.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bunArchiveSupported", () => {
|
||||||
|
test.skipIf(!archiveSupported)(
|
||||||
|
"archiveDir writes with Bun.Archive: files only, contents intact, both formats",
|
||||||
|
async () => {
|
||||||
|
const staging = tmp();
|
||||||
|
writeFileSync(join(staging, "app"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
mkdirSync(join(staging, "deploy"));
|
||||||
|
writeFileSync(join(staging, "deploy", "x.conf"), "x");
|
||||||
|
const archives = await Promise.all(
|
||||||
|
(["tar.gz", "zip"] as const).map(async (format) => {
|
||||||
|
const archive = join(tmp(), `a.${format}`);
|
||||||
|
return {
|
||||||
|
writer: await archiveDir(staging, archive, format),
|
||||||
|
files: await new Bun.Archive(await Bun.file(archive).bytes()).files(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const { writer, files } of archives) {
|
||||||
|
expect(writer).toBe("bun-archive");
|
||||||
|
expect([...files.keys()].toSorted()).toEqual(["app", "deploy/x.conf"]);
|
||||||
|
expect(files.get("app")!.size).toBe(10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("packageApp", () => {
|
||||||
|
test("stages the executable with README, manifest and checksums, archives it and records the archive hash", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
// Build the UI once so the manifest exists, and reuse a stand-in executable: compiling is covered by compile.test.ts.
|
||||||
|
const { buildApp } = await import("../src/build.ts");
|
||||||
|
await buildApp({ dir });
|
||||||
|
const stub = join(tmp(), "hello-m3");
|
||||||
|
writeFileSync(stub, "#!/bin/sh\necho stub\n", { mode: 0o755 });
|
||||||
|
const out = tmp();
|
||||||
|
const report = await packageApp({ dir, executable: stub, out, deploy: true });
|
||||||
|
expect(report.archive).toBe(
|
||||||
|
join(out, `hello-m3-1.2.3-${process.platform}-${process.arch}.tar.gz`),
|
||||||
|
);
|
||||||
|
expect(report.files).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"hello-m3",
|
||||||
|
"README.txt",
|
||||||
|
"SHA256SUMS",
|
||||||
|
"m3-bun.manifest.json",
|
||||||
|
"deploy/docker/Dockerfile",
|
||||||
|
"deploy/systemd/hello-m3.service",
|
||||||
|
"deploy/vscode/.vscode/tasks.json",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(report.archiver).toBe(await archiver());
|
||||||
|
expect(sha256(new Uint8Array(readFileSync(report.archive)))).toBe(report.sha256);
|
||||||
|
expect(readFileSync(report.checksumFile, "utf8")).toBe(
|
||||||
|
`${report.sha256} ${basename(report.archive)}\n`,
|
||||||
|
);
|
||||||
|
expect(statSync(report.archive).size).toBe(report.bytes);
|
||||||
|
const extracted = tmp();
|
||||||
|
Bun.spawnSync(["tar", "-xzf", relative(extracted, report.archive)], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: extracted,
|
||||||
|
});
|
||||||
|
expect(readFileSync(join(extracted, "SHA256SUMS"), "utf8")).toBe(
|
||||||
|
`${sha256(new Uint8Array(readFileSync(stub)))} hello-m3\n`,
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(extracted, "README.txt"), "utf8")).toContain("--healthcheck");
|
||||||
|
if (process.platform !== "win32")
|
||||||
|
expect(statSync(join(extracted, "hello-m3")).mode & 0o111).not.toBe(0);
|
||||||
|
const listing = Bun.spawnSync(["tar", "-tvzf", basename(report.archive)], {
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: dirname(report.archive),
|
||||||
|
stdout: "pipe",
|
||||||
|
}).stdout.toString();
|
||||||
|
expect(listing).toMatch(/-rwxr-xr-x .* (\.\/)?hello-m3\n/);
|
||||||
|
expect(existsSync(join(out, ".staging"))).toBe(false);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("Windows targets default to zip and name the executable .exe", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const { buildApp } = await import("../src/build.ts");
|
||||||
|
await buildApp({ dir });
|
||||||
|
const stub = join(tmp(), "x.exe");
|
||||||
|
writeFileSync(stub, "MZ");
|
||||||
|
const report = await packageApp({ dir, executable: stub, out: tmp(), target: "windows-x64" });
|
||||||
|
expect(report.archive.endsWith("hello-m3-1.2.3-windows-x64.zip")).toBe(true);
|
||||||
|
expect(report.files).toContain("hello-m3.exe");
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildRootfs", () => {
|
||||||
|
test.skipIf(process.platform !== "linux")(
|
||||||
|
"copies the executable to /app/server and every ldd library to its own absolute path",
|
||||||
|
async () => {
|
||||||
|
const dir = join(tmp(), "rootfs");
|
||||||
|
const report = await buildRootfs(Bun.which("true")!, dir);
|
||||||
|
expect(report.libraries.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(statSync(join(dir, "app", "server")).mode & 0o111).not.toBe(0);
|
||||||
|
for (const lib of report.libraries) expect(existsSync(join(dir, lib))).toBe(true);
|
||||||
|
expect(report.libraries.some((l) => /ld-linux|ld-musl/.test(l))).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test.skipIf(process.platform !== "linux")(
|
||||||
|
"a file ldd cannot read is an error that says why",
|
||||||
|
async () => {
|
||||||
|
const script = join(tmp(), "script.sh");
|
||||||
|
writeFileSync(script, "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
await expect(buildRootfs(script, join(tmp(), "rootfs"))).rejects.toThrow(/ldd cannot read/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { m3Bun } from "../src/plugin.ts";
|
||||||
|
import { tsM3Css } from "../src/theme-css.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
async function bundle(
|
||||||
|
files: Record<string, string>,
|
||||||
|
entry: string,
|
||||||
|
options: Parameters<typeof m3Bun>[0] = {},
|
||||||
|
): Promise<{ success: boolean; logs: string[]; text: string }> {
|
||||||
|
const dir = tmp();
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Materialize each fixture file before running the single bundler invocation.
|
||||||
|
await Bun.write(join(dir, name), content);
|
||||||
|
}
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [join(dir, entry)],
|
||||||
|
root: dir,
|
||||||
|
target: "browser",
|
||||||
|
plugins: [m3Bun({ configDir: dir, ...options })],
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
const text = (await Promise.all(result.outputs.map(o => o.text()))).join("\n");
|
||||||
|
return { success: result.success, logs: result.logs.map(String), text };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("m3Bun: the one plugin", () => {
|
||||||
|
test("an explicit TypeScript generator remains a caller override for custom themes", async () => {
|
||||||
|
const { success, text, logs } = await bundle(
|
||||||
|
{
|
||||||
|
"main.css":
|
||||||
|
'@import "m3:tokens.css";\n@import "m3:scheme.css";\nbody { color: var(--md-sys-color-primary); }\n',
|
||||||
|
},
|
||||||
|
"main.css",
|
||||||
|
{ theme: { seed: "#00897b", variant: "vibrant" }, css: tsM3Css },
|
||||||
|
);
|
||||||
|
expect({ success, logs }).toEqual({ success: true, logs: [] });
|
||||||
|
expect(text).toContain("--md-sys-color-primary");
|
||||||
|
expect(text).toContain("--md-sys-shape-corner-medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3:icons/<name> is the SVG markup from the catalogue; variants and errors", async () => {
|
||||||
|
const iconsRoot = tmp();
|
||||||
|
await Bun.write(join(iconsRoot, "home/materialsymbolsoutlined/home_24px.svg"), '<svg xmlns="a"/>');
|
||||||
|
await Bun.write(join(iconsRoot, "home/materialsymbolsrounded/home_fill1_24px.svg"), '<svg xmlns="b"/>');
|
||||||
|
const ok = await bundle(
|
||||||
|
{
|
||||||
|
"main.ts": 'import a from "m3:icons/home"; import b from "m3:icons/rounded/home?fill=1"; console.log(a, b);\n',
|
||||||
|
},
|
||||||
|
"main.ts",
|
||||||
|
{ iconsRoot },
|
||||||
|
);
|
||||||
|
expect(ok.success).toBe(true);
|
||||||
|
expect(ok.text).toContain('<svg xmlns="b"/>');
|
||||||
|
const missing = await bundle(
|
||||||
|
{ "main.ts": 'import a from "m3:icons/no_such_symbol_xyz"; console.log(a);\n' },
|
||||||
|
"main.ts",
|
||||||
|
{ iconsRoot },
|
||||||
|
);
|
||||||
|
expect(missing.success).toBe(false);
|
||||||
|
expect(missing.logs.join("\n")).toContain("no_such_symbol_xyz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3:fonts.css falls back to @aphrody/m3-fonts when no Aphrody OS checkout holds the fonts", async () => {
|
||||||
|
const { success, text, logs } = await bundle({ "main.css": '@import "m3:fonts.css";\n' }, "main.css");
|
||||||
|
expect({ success, logs }).toEqual({ success: true, logs: [] });
|
||||||
|
expect(text).toContain("@font-face");
|
||||||
|
expect(text).toContain("Google Sans Flex");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an app can pass its own css generator for the sheets the TypeScript one does not know", async () => {
|
||||||
|
const { success, text } = await bundle({ "main.css": '@import "m3:spaceui.css";\n' }, "main.css", {
|
||||||
|
css: (config: any) => `/* ${config.sheet} */ :root { --custom: 1; }`,
|
||||||
|
});
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(text).toContain("--custom");
|
||||||
|
const fallback = await bundle({ "main.css": '@import "m3:spaceui.css";\n' }, "main.css", {
|
||||||
|
theme: { seed: "#123456" },
|
||||||
|
});
|
||||||
|
expect(fallback.success).toBe(false);
|
||||||
|
expect(fallback.logs.join("\n")).toContain("native");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default baseline uses the committed native sheet without a TypeScript override", async () => {
|
||||||
|
const { success, text, logs } = await bundle({ "main.ts": 'import "m3:tokens.css";\n' }, "main.ts", {
|
||||||
|
theme: { seed: "#6750A4", variant: "tonalSpot", contrast: 0 },
|
||||||
|
});
|
||||||
|
// Tailwind compiles the sheet's `@theme inline`: Bun's CSS parser has nothing to warn about.
|
||||||
|
expect({ success, logs }).toEqual({ success: true, logs: [] });
|
||||||
|
expect(text).toContain("--md-sys-color-primary: #65558f");
|
||||||
|
expect(text).toContain("--primary: var(--md-sys-color-primary)");
|
||||||
|
expect(text).not.toContain("@theme");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let nativeCorpus = process.env.APHRODY_DESIGN_CORPUS;
|
||||||
|
if (!nativeCorpus) {
|
||||||
|
let dir = import.meta.dir;
|
||||||
|
for (;;) {
|
||||||
|
const corpus = join(dir, "var/mirrors/design-sync");
|
||||||
|
if (existsSync(join(corpus, "tokens.json"))) {
|
||||||
|
nativeCorpus = corpus;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!nativeCorpus)("m3Bun native artifact default", () => {
|
||||||
|
test("the real native pet export reaches Bun.build without an injected generator", async () => {
|
||||||
|
const theme = await Bun.file(join(nativeCorpus!, "tokens.json")).json();
|
||||||
|
const output = await bundle({ "main.ts": 'import "m3:tokens.css";\n' }, "main.ts", {
|
||||||
|
configDir: nativeCorpus,
|
||||||
|
theme: { seed: theme.seed, variant: theme.variant, contrast: theme.contrast_level },
|
||||||
|
tailwind: false,
|
||||||
|
sass: false,
|
||||||
|
});
|
||||||
|
expect(output.success).toBe(true);
|
||||||
|
expect(output.logs.filter(log => log !== "BuildMessage: invalid @ rule encountered: '@theme'")).toEqual([]);
|
||||||
|
if (output.logs.length) console.warn(`[m3Bun native CSS qualification gap] ${output.logs.join("; ")}`);
|
||||||
|
expect(output.text).toContain(`--md-sys-color-primary: ${theme.light.primary}`);
|
||||||
|
expect(output.text).toContain(`--md-sys-color-primary: ${theme.dark.primary}`);
|
||||||
|
expect(output.text).toContain("--primary: var(--md-sys-color-primary)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit CSS generator still takes precedence over a native artifact", async () => {
|
||||||
|
const output = await bundle({ "main.ts": 'import "m3:tokens.css";\n' }, "main.ts", {
|
||||||
|
nativeTheme: join(nativeCorpus!, "tokens.json"),
|
||||||
|
theme: { seed: "#123456" },
|
||||||
|
css: () => ":root { --caller-color: #123456; }",
|
||||||
|
tailwind: false,
|
||||||
|
sass: false,
|
||||||
|
});
|
||||||
|
expect(output.success).toBe(true);
|
||||||
|
expect(output.text).toContain("--caller-color: #123456");
|
||||||
|
expect(output.text).not.toContain("--md-sys-color-primary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("m3:optional/<specifier>", () => {
|
||||||
|
test("a resolvable module is its namespace with resolved: true", async () => {
|
||||||
|
const { success, text, logs } = await bundle(
|
||||||
|
{
|
||||||
|
"main.ts":
|
||||||
|
'import fonts, { resolved } from "m3:optional/@aphrody/m3-fonts"; console.log(resolved, Object.keys(fonts ?? {}).length);\n',
|
||||||
|
},
|
||||||
|
"main.ts",
|
||||||
|
// Specifiers resolve from the app directory: the test's temporary directory has no node_modules, this package does.
|
||||||
|
{ configDir: import.meta.dir },
|
||||||
|
);
|
||||||
|
expect({ success, logs }).toEqual({ success: true, logs: [] });
|
||||||
|
expect(text).toContain("resolved");
|
||||||
|
expect(text).toContain("FONT_STACKS");
|
||||||
|
});
|
||||||
|
test("a module that is not installed builds to null with resolved: false", async () => {
|
||||||
|
const { success, text, logs } = await bundle(
|
||||||
|
{
|
||||||
|
"main.ts":
|
||||||
|
'import brand, { resolved } from "m3:optional/@definitely/not-installed"; console.log(brand, resolved);\n',
|
||||||
|
},
|
||||||
|
"main.ts",
|
||||||
|
);
|
||||||
|
expect({ success, logs }).toEqual({ success: true, logs: [] });
|
||||||
|
expect(text).toContain("= null;");
|
||||||
|
expect(text).toContain("var resolved = false;");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The m3 package modules m3Bun serves (src/plugins-m3.ts), through real `Bun.build` runs. The packages resolve from
|
||||||
|
// the app directory: the first of @aphrody/m3-bun, `M3_BUN_TEST_DEPS` and the workspace's app-ui that has them all.
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { delimiter, dirname, join } from "node:path";
|
||||||
|
import { m3Bun } from "../src/plugin.ts";
|
||||||
|
import { resolveFrom } from "../src/plugins.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const PACKAGE_DIR = dirname(import.meta.dir);
|
||||||
|
const DEP_DIRS = [
|
||||||
|
PACKAGE_DIR,
|
||||||
|
...(Bun.env.M3_BUN_TEST_DEPS?.split(delimiter).filter(Boolean) ?? []),
|
||||||
|
join(PACKAGE_DIR, "../app-ui"),
|
||||||
|
];
|
||||||
|
const m3Deps = DEP_DIRS.find(dir =>
|
||||||
|
["@aphrody/m3-theme/tokens.css", "@aphrody/assets/package.json"].every(s => resolveFrom(s, [dir])),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function bundle(
|
||||||
|
files: Record<string, string>,
|
||||||
|
entry: string,
|
||||||
|
options: Parameters<typeof m3Bun>[0] = {},
|
||||||
|
): Promise<{ success: boolean; logs: string; text: string }> {
|
||||||
|
const dir = tmp();
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Materialize each fixture file before the single bundler invocation.
|
||||||
|
await Bun.write(join(dir, name), content);
|
||||||
|
}
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [join(dir, entry)],
|
||||||
|
root: dir,
|
||||||
|
outdir: join(dir, "dist"),
|
||||||
|
target: "browser",
|
||||||
|
plugins: [m3Bun({ configDir: m3Deps ?? dir, ...options })],
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
const text = (await Promise.all(result.outputs.map(o => o.text()))).join("\n");
|
||||||
|
return { success: result.success, logs: result.logs.map(String).join("\n"), text };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!m3Deps)("m3:theme/, m3:assets/", () => {
|
||||||
|
test("m3:theme/tokens.css is the @aphrody/m3-theme sheet", async () => {
|
||||||
|
const { success, logs, text } = await bundle({ "main.css": '@import "m3:theme/tokens.css";\n' }, "main.css");
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain("--md-sys-color-primary: #65558f");
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3:theme/spaceui.css imported from JS reaches Bun compiled: no `@theme` block left", async () => {
|
||||||
|
const { success, logs, text } = await bundle({ "main.ts": 'import "m3:theme/spaceui.css";\n' }, "main.ts");
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain("--md-sys-color-primary");
|
||||||
|
expect(text).not.toContain("@theme");
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3:assets/<path> is the @aphrody/assets file; ?url inlines it as a data URL", async () => {
|
||||||
|
const file = await bundle(
|
||||||
|
{ "main.ts": 'import a from "m3:assets/icons/Album.png";\nconsole.log(a);\n' },
|
||||||
|
"main.ts",
|
||||||
|
);
|
||||||
|
expect(file.logs).toBe("");
|
||||||
|
expect(file.text).toMatch(/Album-[a-z0-9]+\.png/);
|
||||||
|
expect(file.success).toBe(true);
|
||||||
|
const inline = await bundle(
|
||||||
|
{ "main.ts": 'import a from "m3:assets/icons/Album.png?url";\nconsole.log(a);\n' },
|
||||||
|
"main.ts",
|
||||||
|
);
|
||||||
|
expect(inline.logs).toBe("");
|
||||||
|
expect(inline.text).toContain("data:image/png;base64,");
|
||||||
|
expect(inline.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown file or a query other than m3:assets/?url fails with the package to install", async () => {
|
||||||
|
const missing = await bundle({ "main.css": '@import "m3:theme/nope.css";\n' }, "main.css");
|
||||||
|
expect(missing.success).toBe(false);
|
||||||
|
expect(missing.logs).toContain("m3-bun: m3:theme/nope.css needs @aphrody/m3-theme/nope.css");
|
||||||
|
const query = await bundle({ "main.ts": 'import t from "m3:theme/tokens.css?url";\nconsole.log(t);\n' }, "main.ts");
|
||||||
|
expect(query.success).toBe(false);
|
||||||
|
expect(query.logs).toContain("only m3:assets/ takes a query, and only ?url");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("m3:icons/", () => {
|
||||||
|
test("a local catalogue (iconsRoot) serves the variants; a missing icon names the file", async () => {
|
||||||
|
const root = tmp();
|
||||||
|
await Bun.write(join(root, "home/materialsymbolsoutlined/home_24px.svg"), '<svg id="outlined"/>\n');
|
||||||
|
await Bun.write(join(root, "home/materialsymbolsrounded/home_wght300fill1_24px.svg"), '<svg id="rounded"/>\n');
|
||||||
|
const ok = await bundle(
|
||||||
|
{
|
||||||
|
"main.ts":
|
||||||
|
'import a from "m3:icons/home"; import b from "m3:icons/rounded/home?fill=1&wght=300"; console.log(a, b);\n',
|
||||||
|
},
|
||||||
|
"main.ts",
|
||||||
|
{ iconsRoot: root },
|
||||||
|
);
|
||||||
|
expect(ok.logs).toBe("");
|
||||||
|
expect(ok.text).toContain('<svg id="outlined"/>');
|
||||||
|
expect(ok.text).toContain('<svg id="rounded"/>');
|
||||||
|
expect(ok.success).toBe(true);
|
||||||
|
const missing = await bundle({ "main.ts": 'import a from "m3:icons/nope"; console.log(a);\n' }, "main.ts", {
|
||||||
|
iconsRoot: root,
|
||||||
|
});
|
||||||
|
expect(missing.success).toBe(false);
|
||||||
|
expect(missing.logs).toContain("no icon outlined/nope_24px.svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a catalogue the CDN (M3_ICONS_ORIGIN) is fetched once into the disk cache", async () => {
|
||||||
|
let hits = 0;
|
||||||
|
await using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
hits++;
|
||||||
|
return new URL(req.url).pathname === "/s/i/short-term/release/materialsymbolsoutlined/home/default/24px.svg"
|
||||||
|
? new Response('<svg id="cdn"/>')
|
||||||
|
: new Response("", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const saved = { origin: Bun.env.M3_ICONS_ORIGIN, cache: Bun.env.M3_ICONS_CACHE };
|
||||||
|
Bun.env.M3_ICONS_ORIGIN = server.url.origin;
|
||||||
|
Bun.env.M3_ICONS_CACHE = tmp();
|
||||||
|
try {
|
||||||
|
const files = { "main.ts": 'import a from "m3:icons/home"; console.log(a);\n' };
|
||||||
|
// Skip the forked catalogue when it is checked out: this test is about the CDN.
|
||||||
|
const options = { iconsRoot: undefined };
|
||||||
|
const first = await bundle(files, "main.ts", options);
|
||||||
|
if (first.text.includes("<svg xmlns=")) return;
|
||||||
|
expect(first.logs).toBe("");
|
||||||
|
expect(first.text).toContain('<svg id="cdn"/>');
|
||||||
|
const second = await bundle(files, "main.ts", options);
|
||||||
|
expect(second.text).toContain('<svg id="cdn"/>');
|
||||||
|
expect(hits).toBe(1);
|
||||||
|
const missing = await bundle({ "main.ts": 'import a from "m3:icons/nope"; console.log(a);\n' }, "main.ts");
|
||||||
|
expect(missing.success).toBe(false);
|
||||||
|
expect(missing.logs).toContain("no icon outlined/nope_24px.svg at");
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of [
|
||||||
|
["M3_ICONS_ORIGIN", saved.origin],
|
||||||
|
["M3_ICONS_CACHE", saved.cache],
|
||||||
|
] as const) {
|
||||||
|
if (value === undefined) delete Bun.env[key];
|
||||||
|
else Bun.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalogue = (await import("@aphrody/material-design-icons").catch(() => null)) as {
|
||||||
|
hasSymbol(name: string): boolean;
|
||||||
|
readSymbolSvg(name: string): string;
|
||||||
|
} | null;
|
||||||
|
test.skipIf(!catalogue?.hasSymbol("home"))("m3:icons/ reads the @aphrody/material-design-icons catalogue", async () => {
|
||||||
|
const { success, logs, text } = await bundle(
|
||||||
|
{ "main.ts": 'import a from "m3:icons/home"; console.log(a);\n' },
|
||||||
|
"main.ts",
|
||||||
|
);
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain(JSON.stringify(catalogue!.readSymbolSvg("home").trim()).slice(1, 40));
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3:icon/ is @aphrody/m3-icons' native renderer, or an error that points to m3:icons/", async () => {
|
||||||
|
const { success, logs, text } = await bundle(
|
||||||
|
{ "main.ts": 'import a from "m3:icon/outlined/home?fill=1"; console.log(a);\n' },
|
||||||
|
"main.ts",
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
expect(text).toContain("<svg");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(logs).toMatch(/m3-bun: (native Material Symbols need @aphrody\/m3-icons|@aphrody\/m3-icons renders through)/);
|
||||||
|
expect(logs).toContain("m3:icons/[style/]name serves the SVG catalogue without it");
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// The plugins m3Bun composes, through real `Bun.build` runs. A package resolves from the app directory (`configDir`)
|
||||||
|
// then from @aphrody/m3-bun; `M3_BUN_TEST_DEPS` (path list) adds directories where packages not yet in
|
||||||
|
// @aphrody/m3-bun's dependencies are installed. A suite whose packages resolve nowhere is skipped.
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { delimiter, dirname, join } from "node:path";
|
||||||
|
import type { BuildConfig, BunPlugin } from "bun";
|
||||||
|
import { m3Bun } from "../src/plugin.ts";
|
||||||
|
import { lazyPlugin, M3_OPTIMIZE_IMPORTS, m3OptimizeImports, resolveFrom } from "../src/plugins.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const DEP_DIRS = [dirname(import.meta.dir), ...(Bun.env.M3_BUN_TEST_DEPS?.split(delimiter).filter(Boolean) ?? [])];
|
||||||
|
|
||||||
|
/** The first directory every specifier resolves from. */
|
||||||
|
function depsFor(...specifiers: string[]): string | undefined {
|
||||||
|
return DEP_DIRS.find(dir => specifiers.every(s => resolveFrom(s, [dir])));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(files: Record<string, string>): Promise<string> {
|
||||||
|
const dir = tmp();
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Materialize each fixture file before the single bundler invocation.
|
||||||
|
await Bun.write(join(dir, name), content);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build(
|
||||||
|
dir: string,
|
||||||
|
entry: string,
|
||||||
|
plugin: BunPlugin,
|
||||||
|
config: Partial<BuildConfig> = {},
|
||||||
|
): Promise<{ success: boolean; logs: string; text: string }> {
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [join(dir, entry)],
|
||||||
|
root: dir,
|
||||||
|
target: "browser",
|
||||||
|
plugins: [plugin],
|
||||||
|
throw: false,
|
||||||
|
...config,
|
||||||
|
} as BuildConfig);
|
||||||
|
const text = (await Promise.all(result.outputs.map(o => o.text()))).join("\n");
|
||||||
|
return { success: result.success, logs: result.logs.map(String).join("\n"), text };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lazyPlugin", () => {
|
||||||
|
test("imports the plugin on the first matching module only, then forwards to its hooks", async () => {
|
||||||
|
let loads = 0;
|
||||||
|
const plugin = lazyPlugin({
|
||||||
|
name: "probe",
|
||||||
|
load: async () => {
|
||||||
|
loads++;
|
||||||
|
return {
|
||||||
|
name: "inner",
|
||||||
|
setup(b) {
|
||||||
|
b.onResolve({ filter: /^probe:/ }, args => ({ path: args.path, namespace: "probe" }));
|
||||||
|
b.onLoad({ filter: /.*/, namespace: "probe" }, args => ({
|
||||||
|
contents: `export default ${JSON.stringify(args.path)};`,
|
||||||
|
loader: "js",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onResolve: [{ filter: /^probe:/ }],
|
||||||
|
onLoad: [{ filter: /.*/, namespace: "probe" }],
|
||||||
|
});
|
||||||
|
const plain = await write({ "main.ts": "console.log(1);\n" });
|
||||||
|
expect((await build(plain, "main.ts", plugin)).success).toBe(true);
|
||||||
|
expect(loads).toBe(0);
|
||||||
|
const used = await write({ "main.ts": 'import a from "probe:a"; import b from "probe:b"; console.log(a, b);\n' });
|
||||||
|
const { success, text } = await build(used, "main.ts", plugin);
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(text).toContain('"probe:a"');
|
||||||
|
expect(text).toContain('"probe:b"');
|
||||||
|
expect(loads).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failing import is a build error", async () => {
|
||||||
|
const plugin = lazyPlugin({
|
||||||
|
name: "broken",
|
||||||
|
load: () => Promise.reject(new Error("m3-bun: probe needs @nope/probe")),
|
||||||
|
onLoad: [{ filter: /\.probe$/ }],
|
||||||
|
});
|
||||||
|
const dir = await write({ "main.ts": 'import "./x.probe";\n', "x.probe": "" });
|
||||||
|
const { success, logs } = await build(dir, "main.ts", plugin);
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(logs).toContain("m3-bun: probe needs @nope/probe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("optimizeImports", () => {
|
||||||
|
test("the m3 barrels are listed once, the app's entries kept", () => {
|
||||||
|
expect(m3OptimizeImports(["lodash-es", "@aphrody/m3-react"])).toEqual([...M3_OPTIMIZE_IMPORTS, "lodash-es"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bun.build skips the unused submodules of an m3 barrel", async () => {
|
||||||
|
const dir = await write({
|
||||||
|
"node_modules/@aphrody/m3-react/package.json": '{ "name": "@aphrody/m3-react", "main": "index.ts" }\n',
|
||||||
|
"node_modules/@aphrody/m3-react/index.ts":
|
||||||
|
'export { Button } from "./button.ts";\nexport { Broken } from "./broken.ts";\n',
|
||||||
|
"node_modules/@aphrody/m3-react/button.ts": 'export const Button = "m3-button";\n',
|
||||||
|
"node_modules/@aphrody/m3-react/broken.ts": "export const Broken = ;\n",
|
||||||
|
"main.ts": 'import { Button } from "@aphrody/m3-react";\nconsole.log(Button);\n',
|
||||||
|
});
|
||||||
|
const plain: BunPlugin = { name: "none", setup() {} };
|
||||||
|
const without = await build(dir, "main.ts", plain);
|
||||||
|
expect(without.success).toBe(false);
|
||||||
|
const optimized = await build(dir, "main.ts", plain, { optimizeImports: m3OptimizeImports() });
|
||||||
|
expect(optimized.logs).toBe("");
|
||||||
|
expect(optimized.text).toContain("m3-button");
|
||||||
|
expect(optimized.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const yamlDeps = depsFor("@aphrody/bun-plugin-yaml");
|
||||||
|
describe.skipIf(!yamlDeps)("yaml: @aphrody/bun-plugin-yaml", () => {
|
||||||
|
test("off by default: Bun's own YAML loader; on: js-yaml, which rejects duplicate keys", async () => {
|
||||||
|
const dir = await write({ "data.yaml": "a: 1\nlist: [x, y]\n", "dup.yml": "a: 1\na: 2\n" });
|
||||||
|
await Bun.write(join(dir, "main.ts"), 'import data from "./data.yaml"; console.log(data.list);\n');
|
||||||
|
await Bun.write(join(dir, "dup.ts"), 'import dup from "./dup.yml"; console.log(dup);\n');
|
||||||
|
const native = await build(dir, "dup.ts", m3Bun({ configDir: yamlDeps }));
|
||||||
|
expect(native.success).toBe(true);
|
||||||
|
const ok = await build(dir, "main.ts", m3Bun({ configDir: yamlDeps, yaml: true }));
|
||||||
|
expect(ok.logs).toBe("");
|
||||||
|
expect(ok.text).toContain('"x"');
|
||||||
|
const dup = await build(dir, "dup.ts", m3Bun({ configDir: yamlDeps, yaml: true }));
|
||||||
|
expect(dup.success).toBe(false);
|
||||||
|
expect(dup.logs).toContain("duplicated mapping key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const svelteDeps = depsFor("@aphrody/bun-plugin-svelte", "svelte/compiler");
|
||||||
|
describe.skipIf(!svelteDeps)("svelte: @aphrody/bun-plugin-svelte", () => {
|
||||||
|
test("on by default: components compile and their styles become a CSS output", async () => {
|
||||||
|
const dir = await write({
|
||||||
|
"Counter.svelte":
|
||||||
|
"<script>let count = $state(0);</script>\n<button onclick={() => count++}>{count}</button>\n<style>button { color: rebeccapurple; }</style>\n",
|
||||||
|
"main.ts": 'import Counter from "./Counter.svelte";\nconsole.log(Counter);\n',
|
||||||
|
});
|
||||||
|
const { success, logs, text } = await build(dir, "main.ts", m3Bun({ configDir: svelteDeps }), {
|
||||||
|
external: ["svelte", "svelte/*"],
|
||||||
|
outdir: join(dir, "dist"),
|
||||||
|
});
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain("svelte/internal");
|
||||||
|
expect(text).toContain("color: #639");
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("svelte: false leaves .svelte files to Bun", async () => {
|
||||||
|
const dir = await write({
|
||||||
|
"A.svelte": "<p>{1 + 1}</p>\n",
|
||||||
|
"main.ts": 'import A from "./A.svelte";\nconsole.log(A);\n',
|
||||||
|
});
|
||||||
|
// tailwind: false: an onLoad that returns undefined for a file Bun loads as an asset panics Bun 1.4.3-aphrody.2,
|
||||||
|
// and @aphrody/bun-plugin-tailwind reads .svelte files that way.
|
||||||
|
const { success, text } = await build(
|
||||||
|
dir,
|
||||||
|
"main.ts",
|
||||||
|
m3Bun({ configDir: svelteDeps, svelte: false, tailwind: false }),
|
||||||
|
);
|
||||||
|
expect(text).not.toContain("svelte/internal");
|
||||||
|
expect(text).toContain(".svelte");
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("svelte without its package is an error that names the option", async () => {
|
||||||
|
const dir = await write({
|
||||||
|
"A.svelte": "<p>hi</p>\n",
|
||||||
|
"main.ts": 'import A from "./A.svelte";\nconsole.log(A);\n',
|
||||||
|
// An app whose own @aphrody/bun-plugin-svelte cannot import `svelte/compiler`.
|
||||||
|
"node_modules/@aphrody/bun-plugin-svelte/package.json":
|
||||||
|
'{ "name": "@aphrody/bun-plugin-svelte", "type": "module", "exports": { ".": "./src/index.ts" } }\n',
|
||||||
|
"node_modules/@aphrody/bun-plugin-svelte/src/index.ts":
|
||||||
|
'export { compile } from "svelte/compiler";\nexport function SveltePlugin() { return { name: "x", setup() {} }; }\n',
|
||||||
|
});
|
||||||
|
const { success, logs } = await build(dir, "main.ts", m3Bun({ configDir: dir }));
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(logs).toContain("m3-bun: svelte: importing @aphrody/bun-plugin-svelte failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mdxDeps = depsFor("@aphrody/bun-mdx-rs");
|
||||||
|
describe.skipIf(!mdxDeps)("mdx: @aphrody/bun-mdx-rs", () => {
|
||||||
|
test("mdx: true compiles .mdx to JSX through the native onBeforeParse hook", async () => {
|
||||||
|
const dir = await write({
|
||||||
|
"doc.mdx": "# Hello m3\n\nSome *emphasis*.\n",
|
||||||
|
"main.tsx": 'import Doc from "./doc.mdx";\nconsole.log(Doc);\n',
|
||||||
|
});
|
||||||
|
const { success, logs, text } = await build(dir, "main.tsx", m3Bun({ configDir: mdxDeps, mdx: true }), {
|
||||||
|
external: ["react", "react/*"],
|
||||||
|
});
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain("Hello m3");
|
||||||
|
expect(text).toContain("MDXContent");
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const n2bDeps = depsFor("@aphrody/bun-plugin-n2b/plugin");
|
||||||
|
describe.skipIf(!n2bDeps)("n2b: @aphrody/bun-plugin-n2b", () => {
|
||||||
|
test("n2b aggressive mode rewrites Node APIs to Bun's at load time without touching the file", async () => {
|
||||||
|
const source = 'import fs from "fs";\nexport const text = fs.readFileSync("a.txt", "utf8");\n';
|
||||||
|
const dir = await write({ "main.ts": source });
|
||||||
|
const { success, logs, text } = await build(
|
||||||
|
dir,
|
||||||
|
"main.ts",
|
||||||
|
m3Bun({ configDir: n2bDeps, n2b: { mode: "aggressive", quiet: true } }),
|
||||||
|
{ target: "bun" },
|
||||||
|
);
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain('await Bun.file("a.txt").text()');
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(await Bun.file(join(dir, "main.ts")).text()).toBe(source);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const oxcDeps = depsFor("@aphrody/bun-plugin-oxc");
|
||||||
|
describe.skipIf(!oxcDeps)("oxc: @aphrody/bun-plugin-oxc", () => {
|
||||||
|
test("oxc lint: error fails the build on an oxlint error", async () => {
|
||||||
|
const dir = await write({ "main.ts": "const a: number = 1;\ndebugger;\nconsole.log(a);\n" });
|
||||||
|
const linted = await build(dir, "main.ts", m3Bun({ configDir: oxcDeps, oxc: { lint: "error" } }));
|
||||||
|
expect(linted.success).toBe(false);
|
||||||
|
expect(linted.logs).toContain("oxc lint: 1 problem(s)");
|
||||||
|
expect(linted.logs).toContain("`debugger` statement is not allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oxc native: true transforms TypeScript on Bun's bundler threads", async () => {
|
||||||
|
const dir = await write({ "main.ts": "const a: number = 41;\nexport const b = a + 1;\n" });
|
||||||
|
const { success, logs, text } = await build(dir, "main.ts", m3Bun({ configDir: oxcDeps, oxc: { native: true } }));
|
||||||
|
expect(logs).toBe("");
|
||||||
|
expect(text).toContain("a + 1");
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an enabled fork plugin whose package resolves nowhere fails with what to install", async () => {
|
||||||
|
// The fixture has no node_modules: only @aphrody/m3-bun's own dependencies could answer.
|
||||||
|
if (resolveFrom("@aphrody/bun-plugin-yaml", [import.meta.dir])) return;
|
||||||
|
const dir = await write({ "a.yaml": "a: 1\n", "y.ts": 'import a from "./a.yaml"; console.log(a);\n' });
|
||||||
|
const { success, logs } = await build(dir, "y.ts", m3Bun({ configDir: dir, yaml: true }));
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(logs).toContain("m3-bun: yaml needs @aphrody/bun-plugin-yaml");
|
||||||
|
expect(logs).toContain("bun add @aphrody/bun-plugin-yaml");
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, expect, test } from "bun:test";
|
||||||
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { launch } from "../src/runtime.ts";
|
||||||
|
import { cleanup, freePort, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
test("stopping a launched process is safe concurrently and after completion", async () => {
|
||||||
|
const file = join(tmp(), "server.ts");
|
||||||
|
writeFileSync(file, 'Bun.serve({port:Number(Bun.env.PORT),fetch:()=>new Response("ok")});\n');
|
||||||
|
const port = freePort();
|
||||||
|
const child = await launch(process.execPath, { args: [file], port, env: { PORT: String(port) } });
|
||||||
|
const [first, second] = await Promise.all([child.stop(), child.stop()]);
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(await child.stop()).toEqual(first);
|
||||||
|
await expect(fetch(`http://127.0.0.1:${port}`)).rejects.toBeDefined();
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
test("cancelling startup stops and joins the spawned process", async () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const file = join(dir, "idle.ts");
|
||||||
|
const marker = join(dir, "pid");
|
||||||
|
writeFileSync(
|
||||||
|
file,
|
||||||
|
`await Bun.write(${JSON.stringify(marker)},String(process.pid));setInterval(()=>{},1000);\n`,
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = new Error("cancelled startup");
|
||||||
|
const pending = launch(process.execPath, {
|
||||||
|
args: [file],
|
||||||
|
port: freePort(),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const rejected = pending.then(
|
||||||
|
() => undefined,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Observe the spawned process before cancelling startup.
|
||||||
|
while (!existsSync(marker) && Date.now() < deadline) await Bun.sleep(10);
|
||||||
|
controller.abort(reason);
|
||||||
|
expect(await rejected).toBe(reason);
|
||||||
|
expect(existsSync(marker)).toBe(true);
|
||||||
|
const pid = Number(await Bun.file(marker).text());
|
||||||
|
expect(() => process.kill(pid, 0)).toThrow();
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
test("invalid readiness inputs fail before spawning a process", async () => {
|
||||||
|
await expect(launch("does-not-exist", { port: 0 })).rejects.toThrow("invalid readiness port");
|
||||||
|
await expect(launch("does-not-exist", { port: 4000, path: "//other/health" })).rejects.toThrow(
|
||||||
|
"absolute path",
|
||||||
|
);
|
||||||
|
await expect(launch("does-not-exist", { port: 4000, timeoutMs: 0 })).rejects.toThrow("positive");
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { launch, runtimeSdkPath, smoke } from "../src/runtime.ts";
|
||||||
|
import { cleanup, freePort, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const SERVER = `const port = Number(Bun.env.PORT);
|
||||||
|
Bun.serve({ port, fetch: (req) => new URL(req.url).pathname === "/healthz" ? Response.json({ ok: true }) : new Response("<html></html>", { headers: { "content-type": "text/html" } }) });
|
||||||
|
setInterval(() => {}, 1 << 30);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function script(): string {
|
||||||
|
const file = join(tmp(), "server.ts");
|
||||||
|
writeFileSync(file, SERVER);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runtimeSdkPath", () => {
|
||||||
|
test("resolves the SDK from the selected Bun checkout", () => {
|
||||||
|
expect(runtimeSdkPath("/definitely/not/bun")).toBeUndefined();
|
||||||
|
const root = tmp();
|
||||||
|
const source = join(root, "packages/bun-runtime-sdk/src");
|
||||||
|
mkdirSync(source, { recursive: true });
|
||||||
|
const entry = join(source, "index.ts");
|
||||||
|
writeFileSync(entry, "export const Runtime = {};\n");
|
||||||
|
expect(runtimeSdkPath(root)).toBe(entry);
|
||||||
|
const sdk = runtimeSdkPath();
|
||||||
|
if (sdk) expect(sdk.endsWith("index.ts") || sdk.endsWith("runtime-sdk.ts")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("launch", () => {
|
||||||
|
test("starts a process, waits for its HTTP port, stops it, and says how", async () => {
|
||||||
|
const port = freePort();
|
||||||
|
const child = await launch(process.execPath, {
|
||||||
|
args: [script()],
|
||||||
|
port,
|
||||||
|
env: { PORT: String(port) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(["buv-runtime", "bun-spawn"]).toContain(child.via);
|
||||||
|
expect(child.readyStatus).toBe(200);
|
||||||
|
expect(child.pid).toBeGreaterThan(0);
|
||||||
|
expect((await fetch(`http://127.0.0.1:${port}/healthz`)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
const stopped = await child.stop();
|
||||||
|
expect(stopped).toHaveProperty("exitCode");
|
||||||
|
}
|
||||||
|
await expect(fetch(`http://127.0.0.1:${port}/healthz`)).rejects.toBeDefined();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
test("a process that never listens fails within the timeout and does not linger", async () => {
|
||||||
|
const file = join(tmp(), "idle.ts");
|
||||||
|
writeFileSync(file, "setInterval(() => {}, 1 << 30);\n");
|
||||||
|
const started = Date.now();
|
||||||
|
await expect(
|
||||||
|
launch(process.execPath, {
|
||||||
|
args: [file],
|
||||||
|
port: freePort(),
|
||||||
|
timeoutMs: 600,
|
||||||
|
}),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
expect(Date.now() - started).toBeLessThan(5000);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smoke", () => {
|
||||||
|
test("GETs each path, checks status and content type, reports ok", async () => {
|
||||||
|
const port = freePort();
|
||||||
|
// `launch` runs the program with the given args; a tiny wrapper script makes a runnable "executable".
|
||||||
|
const wrapper = join(tmp(), process.platform === "win32" ? "run.exe" : "run.sh");
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const compiled = Bun.spawnSync(
|
||||||
|
[process.execPath, "build", "--compile", script(), "--outfile", wrapper],
|
||||||
|
{ windowsHide: true, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
expect(compiled.stderr.toString()).not.toContain("error");
|
||||||
|
} else {
|
||||||
|
writeFileSync(wrapper, `#!/bin/sh\nexec ${process.execPath} ${script()}\n`, { mode: 0o755 });
|
||||||
|
}
|
||||||
|
const report = await smoke(wrapper, port, ["/", "/healthz"], {
|
||||||
|
"/": "text/html",
|
||||||
|
"/healthz": "application/json",
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(true);
|
||||||
|
expect(report.checks.map((c) => [c.path, c.status])).toEqual([
|
||||||
|
["/", 200],
|
||||||
|
["/healthz", 200],
|
||||||
|
]);
|
||||||
|
const wrong = await smoke(wrapper, freePort(), ["/"], {
|
||||||
|
"/": "application/json",
|
||||||
|
});
|
||||||
|
expect(wrong.ok).toBe(false);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { startM3Server } from "../src/server.ts";
|
||||||
|
import { m3Auth } from "../src/server-auth.ts";
|
||||||
|
import { m3Cache } from "../src/server-cache.ts";
|
||||||
|
import { m3Db } from "../src/server-db.ts";
|
||||||
|
import { ensureSecret, m3Secret } from "../src/server-secrets.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("m3Db (bun:sqlite)", () => {
|
||||||
|
test("a file database: directory created, WAL, foreign keys, migrations applied once", () => {
|
||||||
|
const path = join(tmp(), "data", "app.sqlite");
|
||||||
|
const migrations = [
|
||||||
|
"CREATE TABLE lists (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
|
||||||
|
(db: import("bun:sqlite").Database) =>
|
||||||
|
db.run("CREATE TABLE items (id INTEGER PRIMARY KEY, list INTEGER NOT NULL REFERENCES lists(id))"),
|
||||||
|
];
|
||||||
|
const db = m3Db({ path, migrations });
|
||||||
|
expect(existsSync(path)).toBe(true);
|
||||||
|
expect(db.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: "wal" });
|
||||||
|
expect(db.query("PRAGMA user_version").get()).toEqual({ user_version: 2 });
|
||||||
|
expect(() => db.run("INSERT INTO items (list) VALUES (42)")).toThrow(/FOREIGN KEY/);
|
||||||
|
db.query("INSERT INTO lists (name) VALUES ($name)").run({ name: "strict params" });
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const again = m3Db({ path, migrations: [...migrations, "ALTER TABLE lists ADD COLUMN color TEXT"] });
|
||||||
|
expect(again.query("PRAGMA user_version").get()).toEqual({ user_version: 3 });
|
||||||
|
expect(again.query("SELECT name, color FROM lists").all()).toEqual([{ name: "strict params", color: null }]);
|
||||||
|
again.close();
|
||||||
|
expect(() => m3Db({ path, migrations })).toThrow(/schema version 3, newer than the 2 migrations/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failing migration rolls back, closes the database and names the step", () => {
|
||||||
|
const path = join(tmp(), "fail.sqlite");
|
||||||
|
expect(() => m3Db({ path, migrations: ["CREATE TABLE a (x)", "CREATE TABLE b (y); SELECT nope FROM missing"] })).toThrow(
|
||||||
|
"m3Db: migration 2 of",
|
||||||
|
);
|
||||||
|
const db = m3Db({ path, migrations: ["CREATE TABLE a (x)"] });
|
||||||
|
expect(db.query("PRAGMA user_version").get()).toEqual({ user_version: 1 });
|
||||||
|
expect(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all()).toEqual([{ name: "a" }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("m3Auth (Bun.password, Bun.CookieMap, Bun.CSRF)", () => {
|
||||||
|
test("register, login, me, CSRF-protected logout over real HTTP", async () => {
|
||||||
|
const auth = m3Auth({ db: m3Db(), secret: "test-secret", secure: false });
|
||||||
|
const server = startM3Server({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
api: {
|
||||||
|
...auth.routes,
|
||||||
|
"/api/whoami": request => Response.json({ email: auth.user(request)?.email ?? null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const base = `http://127.0.0.1:${server.port}/api/m3/auth`;
|
||||||
|
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
|
||||||
|
fetch(`${base}/${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const account = { email: "ada@example.com", password: "correct horse" };
|
||||||
|
expect((await post("register", account)).status).toBe(201);
|
||||||
|
expect((await post("register", { ...account, email: "ADA@example.com" })).status).toBe(409);
|
||||||
|
expect((await post("register", { email: "nope", password: "x" })).status).toBe(400);
|
||||||
|
expect((await post("login", { ...account, password: "wrong password" })).status).toBe(401);
|
||||||
|
expect((await post("login", { email: "who@example.com", password: "whatever1" })).status).toBe(401);
|
||||||
|
|
||||||
|
const login = await post("login", account);
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
const setCookie = login.headers.getSetCookie()[0]!;
|
||||||
|
expect(setCookie).toMatch(/^m3-session=[\w-]{43}; Path=\/; Max-Age=604800; .*HttpOnly; SameSite=Strict/);
|
||||||
|
const { csrf, user } = (await login.json()) as { csrf: string; user: { email: string } };
|
||||||
|
expect(user.email).toBe("ada@example.com");
|
||||||
|
const cookie = setCookie.split(";")[0]!;
|
||||||
|
|
||||||
|
const me = await fetch(`${base}/me`, { headers: { cookie } });
|
||||||
|
expect(((await me.json()) as { user: { email: string } }).user.email).toBe("ada@example.com");
|
||||||
|
expect(await (await fetch(`http://127.0.0.1:${server.port}/api/whoami`, { headers: { cookie } })).json()).toEqual(
|
||||||
|
{
|
||||||
|
email: "ada@example.com",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await post("logout", {}, { cookie })).status).toBe(403);
|
||||||
|
expect((await post("logout", {}, { cookie, "x-csrf-token": auth.csrf.token("another-session") })).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
const logout = await post("logout", {}, { cookie, "x-csrf-token": csrf });
|
||||||
|
expect(logout.status).toBe(204);
|
||||||
|
expect(logout.headers.getSetCookie()[0]).toStartWith("m3-session=;");
|
||||||
|
expect((await fetch(`${base}/me`, { headers: { cookie } })).status).toBe(401);
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("register: false leaves account creation to the app", () => {
|
||||||
|
const auth = m3Auth({ db: m3Db(), secret: "s", register: false, prefix: "/auth" });
|
||||||
|
expect(Object.keys(auth.routes).toSorted()).toEqual(["/auth/login", "/auth/logout", "/auth/me"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("m3Secret (environment, Bun.secrets)", () => {
|
||||||
|
test("the environment variable comes first", async () => {
|
||||||
|
Bun.env.M3_BUN_TEST_SESSION_KEY = "from-env";
|
||||||
|
try {
|
||||||
|
expect(await m3Secret("m3-bun-test.session-key")).toBe("from-env");
|
||||||
|
expect(await ensureSecret("m3-bun-test.session-key")).toBe("from-env");
|
||||||
|
} finally {
|
||||||
|
delete Bun.env.M3_BUN_TEST_SESSION_KEY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensureSecret creates the key once in the OS credential store", async () => {
|
||||||
|
const service = `m3-bun-test-${crypto.randomUUID()}`;
|
||||||
|
const name = "session-key";
|
||||||
|
let created: string;
|
||||||
|
try {
|
||||||
|
created = await ensureSecret(name, { service, env: false });
|
||||||
|
} catch (error) {
|
||||||
|
// No credential store on this machine (headless Linux without a Secret Service): the error must say so.
|
||||||
|
expect(String(error)).toContain("cannot store the secret session-key in the OS credential store");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
expect(created).toMatch(/^[\w-]{43}$/);
|
||||||
|
expect(await ensureSecret(name, { service, env: false })).toBe(created);
|
||||||
|
expect(await m3Secret(name, { service, env: false })).toBe(created);
|
||||||
|
} finally {
|
||||||
|
await Bun.secrets.delete({ service, name });
|
||||||
|
}
|
||||||
|
expect(await m3Secret(name, { service, env: false })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function redisAvailable(): Promise<boolean> {
|
||||||
|
const client = new Bun.RedisClient(Bun.env.REDIS_URL ?? Bun.env.VALKEY_URL, {
|
||||||
|
connectionTimeout: 500,
|
||||||
|
autoReconnect: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!(await redisAvailable()))("m3Cache (Bun.RedisClient)", () => {
|
||||||
|
test("values, wrap and GET responses are cached with a lifetime", async () => {
|
||||||
|
const prefix = `m3-bun-test:${crypto.randomUUID()}:`;
|
||||||
|
const cache = m3Cache({ prefix, ttl: 30 });
|
||||||
|
try {
|
||||||
|
await cache.set("k", { a: 1 });
|
||||||
|
expect(await cache.get<{ a: number }>("k")).toEqual({ a: 1 });
|
||||||
|
const ttl = await cache.client.ttl(`${prefix}k`);
|
||||||
|
expect(ttl).toBeGreaterThan(0);
|
||||||
|
expect(ttl).toBeLessThanOrEqual(30);
|
||||||
|
await cache.delete("k");
|
||||||
|
expect(await cache.get("k")).toBeUndefined();
|
||||||
|
let computed = 0;
|
||||||
|
const compute = () => ++computed;
|
||||||
|
expect(await cache.wrap("w", compute)).toBe(1);
|
||||||
|
expect(await cache.wrap("w", compute)).toBe(1);
|
||||||
|
|
||||||
|
let calls = 0;
|
||||||
|
const handler = cache.route(() => Response.json({ calls: ++calls }));
|
||||||
|
const request = () => handler(new Request("http://m3.test/api/feed?x=1") as never);
|
||||||
|
const miss = await request();
|
||||||
|
expect(miss.headers.get("x-cache")).toBe("MISS");
|
||||||
|
const hit = await request();
|
||||||
|
expect(hit.headers.get("x-cache")).toBe("HIT");
|
||||||
|
expect(await hit.json()).toEqual({ calls: 1 });
|
||||||
|
expect(
|
||||||
|
(await handler(new Request("http://m3.test/api/feed", { method: "POST" }) as never)).headers.has("x-cache"),
|
||||||
|
).toBe(false);
|
||||||
|
} finally {
|
||||||
|
cache.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createM3App, DEFAULT_CSP, dirSource, startM3Server } from "../src/server.ts";
|
||||||
|
import { pagePath } from "../src/server-crawl.ts";
|
||||||
|
import { cspWithNonce } from "../src/server-html.ts";
|
||||||
|
import { m3ServerRoutes } from "../src/server-m3.ts";
|
||||||
|
import { renderMarkdown } from "../src/server-markdown.ts";
|
||||||
|
import { publish } from "../src/server-realtime.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
const resolvable = (specifier: string): boolean => {
|
||||||
|
try {
|
||||||
|
Bun.resolveSync(specifier, import.meta.dir);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hasM3Next = resolvable("@aphrody/m3-next");
|
||||||
|
|
||||||
|
let dir = "";
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = tmp();
|
||||||
|
mkdirSync(join(dir, "docs"));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "index.html"),
|
||||||
|
'<!doctype html><html class="m3"><head><title>home</title></head><body><script src="/app.js"></script><script>boot()</script></body></html>',
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, "about.html"), "<!doctype html><html><head></head><body>about</body></html>");
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "docs", "guide.md"),
|
||||||
|
"---\ntitle: The guide\ndescription: How to\n---\n# Heading\n\nSee https://example.com <script>alert(1)</script>\n",
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, "app.js"), "boot = () => {};");
|
||||||
|
});
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const get = (app: ReturnType<typeof createM3App>, path: string, headers: Record<string, string> = {}) =>
|
||||||
|
app.fetch(new Request(`http://m3.test${path}`, { headers }));
|
||||||
|
|
||||||
|
const themedApp = () => createM3App({ files: dirSource(dir), theme: true, nonce: true });
|
||||||
|
|
||||||
|
const open = (url: string): Promise<{ socket: WebSocket; next(): Promise<unknown> }> => {
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
const queue: unknown[] = [];
|
||||||
|
const waiting: ((value: unknown) => void)[] = [];
|
||||||
|
socket.addEventListener("message", event => {
|
||||||
|
const data = JSON.parse(String(event.data));
|
||||||
|
const resolve = waiting.shift();
|
||||||
|
if (resolve) resolve(data);
|
||||||
|
else queue.push(data);
|
||||||
|
});
|
||||||
|
const next = () => (queue.length ? Promise.resolve(queue.shift()) : new Promise(resolve => waiting.push(resolve)));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
socket.addEventListener("open", () => resolve({ socket, next }));
|
||||||
|
socket.addEventListener("error", () => reject(new Error(`cannot open ${url}`)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CSP nonce (HTMLRewriter)", () => {
|
||||||
|
test("cspWithNonce extends script-src, or derives it from default-src", () => {
|
||||||
|
expect(cspWithNonce("default-src 'self'; script-src 'self'", "abc")).toBe(
|
||||||
|
"default-src 'self'; script-src 'self' 'nonce-abc'",
|
||||||
|
);
|
||||||
|
expect(cspWithNonce("default-src 'self' https:", "abc")).toBe(
|
||||||
|
"default-src 'self' https:; script-src 'self' https: 'nonce-abc'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every served page gets a fresh nonce on its scripts and in its CSP", async () => {
|
||||||
|
const app = createM3App({ files: dirSource(dir), nonce: true });
|
||||||
|
const first = await get(app, "/");
|
||||||
|
const html = await first.text();
|
||||||
|
const nonces = [...html.matchAll(/<script[^>]* nonce="([^"]+)"/g)].map(match => match[1]);
|
||||||
|
expect(nonces).toHaveLength(2);
|
||||||
|
expect(new Set(nonces).size).toBe(1);
|
||||||
|
expect(first.headers.get("content-security-policy")).toBe(cspWithNonce(DEFAULT_CSP, nonces[0]!));
|
||||||
|
const second = await (await get(app, "/")).text();
|
||||||
|
expect(second).not.toContain(nonces[0]!);
|
||||||
|
expect((await get(app, "/app.js")).headers.get("content-security-policy")).toBe(DEFAULT_CSP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasM3Next)("theme (@aphrody/m3-next)", () => {
|
||||||
|
test("the m3-theme cookie sets the class, color-scheme and one theme-color", async () => {
|
||||||
|
const res = await get(themedApp(), "/", { cookie: "m3-theme=dark" });
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain('<html class="m3 dark"');
|
||||||
|
expect(html).toContain('data-m3-theme="dark"');
|
||||||
|
expect(html).toContain('style="color-scheme:dark"');
|
||||||
|
expect(html.match(/<meta name="theme-color" content="#[0-9a-f]{6}">/gi)).toHaveLength(1);
|
||||||
|
expect(res.headers.get("vary")).toBe("Cookie");
|
||||||
|
expect(res.headers.getSetCookie()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a cookie: system mode, the pre-paint script with the nonce, both metas, and the cookie set", async () => {
|
||||||
|
const res = await get(themedApp(), "/about.html");
|
||||||
|
const html = await res.text();
|
||||||
|
const nonce = /'nonce-([^']+)'/.exec(res.headers.get("content-security-policy")!)![1];
|
||||||
|
expect(html).toContain(`<head><script nonce="${nonce}">`);
|
||||||
|
expect(html).toContain('data-m3-theme="system"');
|
||||||
|
expect(html.match(/<meta name="theme-color" media=/g)).toHaveLength(2);
|
||||||
|
expect(res.headers.getSetCookie()[0]).toStartWith("m3-theme=system");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Markdown pages (Bun.markdown, Bun.YAML)", () => {
|
||||||
|
test("renderMarkdown: front matter title, heading ids, GFM tag filter, autolinks", () => {
|
||||||
|
const page = renderMarkdown("---\ntitle: T & co\nlang: fr\n---\n# Hi\n\nwww.bun.sh <style>x</style>\n");
|
||||||
|
expect(page.data).toEqual({ title: "T & co", lang: "fr" });
|
||||||
|
expect(page.title).toBe("T & co");
|
||||||
|
expect(page.html).toContain('<html lang="fr">');
|
||||||
|
expect(page.html).toContain('<h1 id="hi">Hi</h1>');
|
||||||
|
expect(page.html).toContain('<a href="http://www.bun.sh">');
|
||||||
|
expect(page.html).toContain("<style>");
|
||||||
|
expect(renderMarkdown("# Only *heading*").title).toBe("Only heading");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browsers get the page, other clients the source; /docs/guide finds guide.md", async () => {
|
||||||
|
const app = createM3App({ files: dirSource(dir), markdown: { stylesheet: "/md.css" } });
|
||||||
|
const page = await get(app, "/docs/guide.md", { accept: "text/html" });
|
||||||
|
expect(page.headers.get("content-type")).toStartWith("text/html");
|
||||||
|
const html = await page.text();
|
||||||
|
expect(html).toContain("<title>The guide</title>");
|
||||||
|
expect(html).toContain('<meta name="description" content="How to">');
|
||||||
|
expect(html).toContain('<link rel="stylesheet" href="/md.css">');
|
||||||
|
expect(html).not.toContain("<script>alert");
|
||||||
|
expect(await (await get(app, "/docs/guide")).text()).toContain('<main class="m3-markdown">');
|
||||||
|
expect(await (await get(app, "/docs/guide.md")).text()).toStartWith("---\ntitle: The guide");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crawler files", () => {
|
||||||
|
test("pagePath maps the build's files to public URLs", () => {
|
||||||
|
expect(["/index.html", "/a/index.html", "/b.html", "/c.md", "/d.js"].map(pagePath)).toEqual([
|
||||||
|
"/",
|
||||||
|
"/a/",
|
||||||
|
"/b",
|
||||||
|
"/c.md",
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!hasM3Next)("sitemap.xml, robots.txt and llms.txt from the file list", async () => {
|
||||||
|
const app = createM3App({
|
||||||
|
name: "site",
|
||||||
|
files: dirSource(dir),
|
||||||
|
crawl: { origin: "https://m3.example", summary: "A site.", disallow: ["/about"] },
|
||||||
|
});
|
||||||
|
const sitemap = await (await get(app, "/sitemap.xml")).text();
|
||||||
|
expect(sitemap).toContain("<loc>https://m3.example/</loc>");
|
||||||
|
expect(sitemap).toContain("<loc>https://m3.example/docs/guide.md</loc>");
|
||||||
|
expect(sitemap).not.toContain("/about");
|
||||||
|
const robots = await (await get(app, "/robots.txt")).text();
|
||||||
|
expect(robots).toContain("Disallow: /about");
|
||||||
|
expect(robots).toContain("Sitemap: https://m3.example/sitemap.xml");
|
||||||
|
const llms = await (await get(app, "/llms.txt")).text();
|
||||||
|
expect(llms).toStartWith("# site\n\n> A site.");
|
||||||
|
expect(llms).toContain("## Docs\n\n- [/docs/guide.md](https://m3.example/docs/guide.md)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("realtime (Bun WebSocket, server.publish)", () => {
|
||||||
|
test("subscribers of a topic receive what the server publishes, and relayed client messages", async () => {
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", files: dirSource(dir), realtime: { relay: true } });
|
||||||
|
try {
|
||||||
|
const ws = `ws://127.0.0.1:${server.port}/api/m3/ws`;
|
||||||
|
const a = await open(`${ws}?topic=news&topic=chat`);
|
||||||
|
const b = await open(`${ws}?topic=chat`);
|
||||||
|
expect(publish(server, "news", { n: 1 })).toBeGreaterThan(0);
|
||||||
|
expect(await a.next()).toEqual({ topic: "news", data: { n: 1 } });
|
||||||
|
a.socket.send(JSON.stringify({ topic: "chat", data: "hello" }));
|
||||||
|
expect(await b.next()).toEqual({ topic: "chat", data: "hello" });
|
||||||
|
b.socket.send(JSON.stringify({ topic: "news", data: "not subscribed" }));
|
||||||
|
publish(server, "chat", "from server");
|
||||||
|
expect(await a.next()).toEqual({ topic: "chat", data: "from server" });
|
||||||
|
a.socket.close();
|
||||||
|
b.socket.close();
|
||||||
|
const http = `http://127.0.0.1:${server.port}/api/m3/ws`;
|
||||||
|
expect((await fetch(`${http}?topic=a%20b`)).status).toBe(400);
|
||||||
|
expect((await fetch(`${http}?topic=ok`)).status).toBe(426);
|
||||||
|
expect((await fetch(`http://127.0.0.1:${server.port}/`)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the topics option can refuse a connection", async () => {
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", realtime: { topics: () => undefined } });
|
||||||
|
try {
|
||||||
|
expect((await fetch(`http://127.0.0.1:${server.port}/api/m3/ws?topic=x`)).status).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bun.serve routes from @aphrody/m3/server", () => {
|
||||||
|
test("m3ServerRoutes: an .html page bundled by Bun.serve and a proxy prefix, next to the file server", async () => {
|
||||||
|
const upstream = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
fetch: request => new Response(`upstream ${new URL(request.url).pathname}`),
|
||||||
|
});
|
||||||
|
const routes = await m3ServerRoutes({
|
||||||
|
cwd: dir,
|
||||||
|
routes: { "/about-page": "about.html" },
|
||||||
|
proxy: { "/rpc": `http://127.0.0.1:${upstream.port}` },
|
||||||
|
});
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", files: dirSource(dir), routes });
|
||||||
|
try {
|
||||||
|
const base = `http://127.0.0.1:${server.port}`;
|
||||||
|
expect(await (await fetch(`${base}/about-page`)).text()).toContain("about");
|
||||||
|
expect(await (await fetch(`${base}/rpc/x?y=1`)).text()).toBe("upstream /rpc/x");
|
||||||
|
expect(await (await fetch(`${base}/`)).text()).toContain("<title>home</title>");
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
await upstream.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { startM3Server } from "../src/server.ts";
|
||||||
|
import { agUiRoute } from "../src/server-agui.ts";
|
||||||
|
import { m3McpRoutes } from "../src/server-mcp.ts";
|
||||||
|
import { startNextApp } from "../src/server-next.ts";
|
||||||
|
import { loadOptional, packageDir } from "../src/server-optional.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const resolvable = (specifier: string): boolean => {
|
||||||
|
try {
|
||||||
|
Bun.resolveSync(specifier, import.meta.dir);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("optional integrations", () => {
|
||||||
|
test("a missing package fails with the feature and the package to add", async () => {
|
||||||
|
await expect(loadOptional("@aphrody/not-installed/sub", "demo")).rejects.toThrow(
|
||||||
|
"m3-bun demo: cannot load @aphrody/not-installed/sub; add @aphrody/not-installed to the app's dependencies",
|
||||||
|
);
|
||||||
|
expect(() => packageDir("@aphrody/not-installed", "demo")).toThrow("@aphrody/not-installed is not installed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!resolvable("@aphrody/m3-mcp"))("M3 MCP tools over HTTP (@aphrody/m3-mcp)", () => {
|
||||||
|
for (const isolate of [false, true])
|
||||||
|
test(`list and call ${isolate ? "in a tool-host child" : "in process"}`, async () => {
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", api: m3McpRoutes({ isolate }) });
|
||||||
|
try {
|
||||||
|
const base = `http://127.0.0.1:${server.port}/api/m3/mcp`;
|
||||||
|
const { tools } = (await (await fetch(base)).json()) as { tools: { name: string }[] };
|
||||||
|
expect(tools.map(tool => tool.name)).toContain("m3_token_lookup");
|
||||||
|
const call = (tool: string, body: string) => fetch(`${base}/${tool}`, { method: "POST", body });
|
||||||
|
const found = await call("m3_token_lookup", JSON.stringify({ query: "primary" }));
|
||||||
|
expect(found.status).toBe(200);
|
||||||
|
const result = (await found.json()) as { content: { type: string; text: string }[] };
|
||||||
|
expect(result.content[0]!.text).toContain("--md-sys-color-primary");
|
||||||
|
const unknown = await call("nope", "{}");
|
||||||
|
expect(unknown.status).toBe(422);
|
||||||
|
expect(await unknown.json()).toEqual({ content: [{ type: "text", text: "unknown tool nope" }], isError: true });
|
||||||
|
expect((await call("m3_token_lookup", "{not json")).status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!resolvable("@aphrody/m3-ai/ag-ui"))("AG-UI endpoint (@aphrody/m3-ai/ag-ui)", async () => {
|
||||||
|
const agUi = resolvable("@aphrody/m3-ai/ag-ui") ? await import("@aphrody/m3-ai/ag-ui") : undefined;
|
||||||
|
|
||||||
|
test("an async generator agent streams to HttpAgent, the AG-UI client", async () => {
|
||||||
|
const { EventType, HttpAgent } = agUi!;
|
||||||
|
const route = agUiRoute(async function* (input) {
|
||||||
|
yield { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId };
|
||||||
|
yield { type: EventType.TEXT_MESSAGE_START, messageId: "m1", role: "assistant" };
|
||||||
|
yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "m1", delta: `echo: ${input.messages.length}` };
|
||||||
|
yield { type: EventType.TEXT_MESSAGE_END, messageId: "m1" };
|
||||||
|
yield { type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId };
|
||||||
|
} as Parameters<typeof agUiRoute>[0]);
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", api: { "/api/m3/ag-ui": route } });
|
||||||
|
try {
|
||||||
|
const url = `http://127.0.0.1:${server.port}/api/m3/ag-ui`;
|
||||||
|
const agent = new HttpAgent({ url, threadId: "t1" });
|
||||||
|
const seen: string[] = [];
|
||||||
|
const { newMessages } = await agent.runAgent(
|
||||||
|
{ runId: "r1" },
|
||||||
|
{ onEvent: ({ event }) => void seen.push(event.type) },
|
||||||
|
);
|
||||||
|
expect(seen).toEqual([
|
||||||
|
"RUN_STARTED",
|
||||||
|
"TEXT_MESSAGE_START",
|
||||||
|
"TEXT_MESSAGE_CONTENT",
|
||||||
|
"TEXT_MESSAGE_END",
|
||||||
|
"RUN_FINISHED",
|
||||||
|
]);
|
||||||
|
expect(newMessages).toEqual([{ id: "m1", role: "assistant", content: "echo: 0" }]);
|
||||||
|
|
||||||
|
const input = JSON.stringify({ threadId: "t", runId: "r", messages: [] });
|
||||||
|
const proto = await fetch(url, { method: "POST", headers: { accept: agUi!.AGUI_MEDIA_TYPE }, body: input });
|
||||||
|
expect(proto.headers.get("content-type")).toBe(agUi!.AGUI_MEDIA_TYPE);
|
||||||
|
const bytes = await proto.bytes();
|
||||||
|
const first = new DataView(bytes.buffer).getUint32(0);
|
||||||
|
expect(agUi!.decodeProto(bytes.subarray(4, 4 + first)).type).toBe(EventType.RUN_STARTED);
|
||||||
|
expect((await fetch(url, { method: "POST", body: "{}" })).status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an Observable agent's error ends the stream with RUN_ERROR", async () => {
|
||||||
|
const { EventType } = agUi!;
|
||||||
|
const route = agUiRoute({
|
||||||
|
run: input => ({
|
||||||
|
subscribe(observer) {
|
||||||
|
observer.next({ type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId } as never);
|
||||||
|
observer.error(new Error("model offline"));
|
||||||
|
return { unsubscribe() {} };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const server = startM3Server({ port: 0, hostname: "127.0.0.1", api: { "/api/m3/ag-ui": route } });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${server.port}/api/m3/ag-ui`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ threadId: "t", runId: "r", messages: [] }),
|
||||||
|
});
|
||||||
|
expect(res.headers.get("content-type")).toBe("text/event-stream");
|
||||||
|
const events = (await res.text())
|
||||||
|
.split("\n\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(frame => JSON.parse(frame.replace(/^data: /, "")) as Record<string, unknown>);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "RUN_STARTED", threadId: "t", runId: "r" },
|
||||||
|
{ type: "RUN_ERROR", message: "model offline" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeNextApp = (): string => {
|
||||||
|
const dir = tmp("m3-bun-next-");
|
||||||
|
mkdirSync(join(dir, "node_modules", "next", "dist", "bin"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "app", dependencies: { next: "*" } }));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "node_modules", "next", "package.json"),
|
||||||
|
JSON.stringify({ name: "next", version: "16.0.0" }),
|
||||||
|
);
|
||||||
|
// The CLI contract m3-bun relies on: `next <mode> -p <port> -H <host>`, then the `- Local:` line once serving.
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "node_modules", "next", "dist", "bin", "next"),
|
||||||
|
[
|
||||||
|
"const [mode, , port, , hostname] = process.argv.slice(2);",
|
||||||
|
"const server = Bun.serve({ port: Number(port), hostname, fetch: async req =>",
|
||||||
|
" Response.json({ mode, path: new URL(req.url).pathname, method: req.method, body: await req.text(), node: typeof Bun }) });",
|
||||||
|
"console.log(' ▲ Next.js 16.0.0');",
|
||||||
|
"console.log(' - Local: http://localhost:' + server.port);",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
return dir;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!resolvable("@aphrody/next-bun/run"))("Next.js app behind m3-bun (@aphrody/next-bun)", () => {
|
||||||
|
test("next runs on Bun on a free port and m3-bun proxies the requests it does not answer", async () => {
|
||||||
|
const next = await startNextApp({ dir: fakeNextApp() });
|
||||||
|
const server = startM3Server({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
api: { routes: { "/api/own": () => new Response("m3") }, fetch: (request: Request) => next.fetch(request) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(next.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||||
|
const base = `http://127.0.0.1:${server.port}`;
|
||||||
|
expect(await (await fetch(`${base}/api/own`)).text()).toBe("m3");
|
||||||
|
expect(await (await fetch(`${base}/blog/1`, { method: "POST", body: "hi" })).json()).toEqual({
|
||||||
|
mode: "dev",
|
||||||
|
path: "/blog/1",
|
||||||
|
method: "POST",
|
||||||
|
body: "hi",
|
||||||
|
node: "object",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
await next.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an app without next, and a next that never serves, fail explicitly", async () => {
|
||||||
|
await expect(startNextApp({ dir: tmp() })).rejects.toThrow("has no next dependency");
|
||||||
|
const dir = fakeNextApp();
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "node_modules", "next", "dist", "bin", "next"),
|
||||||
|
"console.error('boom: no pages'); process.exit(1);",
|
||||||
|
);
|
||||||
|
await expect(startNextApp({ dir })).rejects.toThrow("boom: no pages");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, utimesSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
||||||
|
import type { M3Request } from "../src/api.ts";
|
||||||
|
import {
|
||||||
|
cacheControl,
|
||||||
|
createM3App,
|
||||||
|
DEFAULT_CSP,
|
||||||
|
dirSource,
|
||||||
|
embeddedSource,
|
||||||
|
pickEncoding,
|
||||||
|
resolvePort,
|
||||||
|
startM3Server,
|
||||||
|
} from "../src/server.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
const BIG_JS = `export const x = "${"abcdefghij".repeat(400)}";\n`;
|
||||||
|
let dir = "";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = tmp();
|
||||||
|
mkdirSync(join(dir, "assets"));
|
||||||
|
writeFileSync(join(dir, "index.html"), "<!doctype html><title>home</title>");
|
||||||
|
writeFileSync(join(dir, "chunk-abcd1234.js"), BIG_JS);
|
||||||
|
writeFileSync(join(dir, "app.js"), BIG_JS);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "assets", "logo.svg"),
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg">${"<g/>".repeat(400)}</svg>`,
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, "app.js.map"), "{}");
|
||||||
|
writeFileSync(join(dir, "docs.html"), "<title>docs</title>");
|
||||||
|
utimesSync(join(dir, "index.html"), 1_700_000_000, 1_700_000_000);
|
||||||
|
});
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const get = (
|
||||||
|
app: ReturnType<typeof createM3App>,
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
method = "GET",
|
||||||
|
) => app.fetch(new Request(`http://m3.test${path}`, { method, headers }));
|
||||||
|
|
||||||
|
const staticApp = () => createM3App({ name: "t", version: "9.9.9", files: dirSource(dir) });
|
||||||
|
|
||||||
|
describe("static files", () => {
|
||||||
|
test("the entry answers / and keeps no-cache; sourcemaps are not served", async () => {
|
||||||
|
const res = await get(staticApp(), "/");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toStartWith("text/html");
|
||||||
|
expect(res.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(await res.text()).toContain("<title>home</title>");
|
||||||
|
expect((await get(staticApp(), "/app.js.map")).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("security headers and the default CSP are on every file response", async () => {
|
||||||
|
const res = await get(staticApp(), "/index.html");
|
||||||
|
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(res.headers.get("content-security-policy")).toBe(DEFAULT_CSP);
|
||||||
|
const none = createM3App({ files: dirSource(dir), csp: false });
|
||||||
|
expect((await get(none, "/index.html")).headers.get("content-security-policy")).toBeNull();
|
||||||
|
const custom = createM3App({ files: dirSource(dir), csp: "default-src 'none'" });
|
||||||
|
expect((await get(custom, "/index.html")).headers.get("content-security-policy")).toBe(
|
||||||
|
"default-src 'none'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content-hashed names are immutable, others expire in an hour", async () => {
|
||||||
|
expect((await get(staticApp(), "/chunk-abcd1234.js")).headers.get("cache-control")).toBe(
|
||||||
|
"public, max-age=31536000, immutable",
|
||||||
|
);
|
||||||
|
expect((await get(staticApp(), "/app.js")).headers.get("cache-control")).toBe(
|
||||||
|
"public, max-age=3600",
|
||||||
|
);
|
||||||
|
expect(cacheControl("/x.html", "text/html;charset=utf-8")).toBe("no-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ETag revalidation answers 304 and a changed validator does not", async () => {
|
||||||
|
const first = await get(staticApp(), "/app.js");
|
||||||
|
const etag = first.headers.get("etag")!;
|
||||||
|
expect(etag).toStartWith('W/"');
|
||||||
|
expect((await get(staticApp(), "/app.js", { "if-none-match": etag })).status).toBe(304);
|
||||||
|
expect((await get(staticApp(), "/app.js", { "if-none-match": 'W/"other"' })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compressible files are served br or gzip when accepted, identity otherwise, and round-trip", async () => {
|
||||||
|
const br = await get(staticApp(), "/app.js", { "accept-encoding": "gzip, br" });
|
||||||
|
expect(br.headers.get("content-encoding")).toBe("br");
|
||||||
|
expect(br.headers.get("vary")).toContain("Accept-Encoding");
|
||||||
|
expect(brotliDecompressSync(new Uint8Array(await br.arrayBuffer())).toString()).toBe(BIG_JS);
|
||||||
|
const gz = await get(staticApp(), "/app.js", { "accept-encoding": "gzip" });
|
||||||
|
expect(gz.headers.get("content-encoding")).toBe("gzip");
|
||||||
|
expect(gunzipSync(new Uint8Array(await gz.arrayBuffer())).toString()).toBe(BIG_JS);
|
||||||
|
const plain = await get(staticApp(), "/app.js");
|
||||||
|
expect(plain.headers.get("content-encoding")).toBeNull();
|
||||||
|
expect(await plain.text()).toBe(BIG_JS);
|
||||||
|
expect(
|
||||||
|
(await get(staticApp(), "/assets/logo.svg", { "accept-encoding": "br" })).headers.get(
|
||||||
|
"content-encoding",
|
||||||
|
),
|
||||||
|
).toBe("br");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HEAD returns headers and no body", async () => {
|
||||||
|
const res = await get(staticApp(), "/app.js", {}, "HEAD");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-length")).toBe(String(BIG_JS.length));
|
||||||
|
expect(await res.text()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SPA fallback: extension-less HTML navigations get index.html, assets and other methods do not", async () => {
|
||||||
|
const nav = await get(staticApp(), "/some/client/route", {
|
||||||
|
accept: "text/html,application/xhtml+xml",
|
||||||
|
});
|
||||||
|
expect(nav.status).toBe(200);
|
||||||
|
expect(await nav.text()).toContain("<title>home</title>");
|
||||||
|
expect((await get(staticApp(), "/some/route", { accept: "application/json" })).status).toBe(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
expect((await get(staticApp(), "/missing.js", { accept: "text/html" })).status).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await staticApp().fetch(
|
||||||
|
new Request("http://m3.test/some/route", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
const off = createM3App({ files: dirSource(dir), spa: false });
|
||||||
|
expect((await get(off, "/some/route", { accept: "text/html" })).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a directory path serves its index, a bare name serves name/index.html when present", async () => {
|
||||||
|
mkdirSync(join(dir, "guide"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "guide", "index.html"), "<title>guide</title>");
|
||||||
|
const withGuide = createM3App({ files: dirSource(dir) });
|
||||||
|
expect(await (await get(withGuide, "/guide/")).text()).toContain("guide");
|
||||||
|
expect(await (await get(withGuide, "/guide")).text()).toContain("guide");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("path traversal and bad encodings are refused", async () => {
|
||||||
|
// The URL parser already folds `%2e%2e` dot segments, so those arrive as plain missing paths; encoded
|
||||||
|
// separators and bad encodings reach the guard. Either way nothing outside the build directory is served.
|
||||||
|
for (const path of [
|
||||||
|
"/%2e%2e/%2e%2e/etc/passwd",
|
||||||
|
"/..%2f..%2fetc/passwd",
|
||||||
|
"/%2e%2e%2f%2e%2e%2fetc/passwd",
|
||||||
|
"/%E0%A4%A",
|
||||||
|
"/a%00b",
|
||||||
|
]) {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Check each rejected path against the shared fixture app before proceeding.
|
||||||
|
const res = await get(staticApp(), path);
|
||||||
|
expect([400, 404]).toContain(res.status);
|
||||||
|
}
|
||||||
|
expect((await get(staticApp(), "/%E0%A4%A")).status).toBe(400);
|
||||||
|
expect((await get(staticApp(), "/a%00b")).status).toBe(400);
|
||||||
|
expect((await get(staticApp(), "/..%2f..%2fetc/passwd")).status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health, info and the API", () => {
|
||||||
|
const app = createM3App({
|
||||||
|
name: "demo",
|
||||||
|
version: "1.0.0",
|
||||||
|
buildId: "abc",
|
||||||
|
files: dirSource(dir),
|
||||||
|
info: { extra: true },
|
||||||
|
api: {
|
||||||
|
"/api/ping": () => Response.json({ pong: true }),
|
||||||
|
"/api/shadow": () => Response.json({ from: "api" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/healthz and /api/m3/info", async () => {
|
||||||
|
const health = await (await get(app, "/healthz")).json();
|
||||||
|
expect(health).toMatchObject({ ok: true, name: "demo", version: "1.0.0" });
|
||||||
|
const info = await (await get(app, "/api/m3/info")).json();
|
||||||
|
expect(info).toMatchObject({
|
||||||
|
name: "demo",
|
||||||
|
version: "1.0.0",
|
||||||
|
buildId: "abc",
|
||||||
|
bun: Bun.version,
|
||||||
|
extra: true,
|
||||||
|
});
|
||||||
|
expect(info.api).toEqual(["/api/ping", "/api/shadow"]);
|
||||||
|
expect(info.files).toBeGreaterThan(3);
|
||||||
|
expect((await get(createM3App({ health: false }), "/healthz")).status).toBe(404);
|
||||||
|
expect((await get(createM3App({ health: "/ready" }), "/ready")).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("API routes answer before static files, a fallback fetch can pass or answer", async () => {
|
||||||
|
expect(await (await get(app, "/api/ping")).json()).toEqual({ pong: true });
|
||||||
|
const fallback = createM3App({
|
||||||
|
files: dirSource(dir),
|
||||||
|
api: {
|
||||||
|
routes: {},
|
||||||
|
fetch: (request: Request) =>
|
||||||
|
new URL(request.url).pathname === "/custom" ? new Response("custom") : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await (await get(fallback, "/custom")).text()).toBe("custom");
|
||||||
|
expect(await (await get(fallback, "/index.html")).text()).toContain("home");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no files: only API and health answer, anything else is 404", async () => {
|
||||||
|
const apiOnly = createM3App({ api: { "/api/x": () => new Response("x") } });
|
||||||
|
expect(await (await get(apiOnly, "/api/x")).text()).toBe("x");
|
||||||
|
expect((await get(apiOnly, "/")).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("embedded files", () => {
|
||||||
|
test("an embedded source serves what the generated entry maps, with a build-id ETag", async () => {
|
||||||
|
const source = embeddedSource(
|
||||||
|
{ "/index.html": join(dir, "index.html"), "/app.js": join(dir, "app.js") },
|
||||||
|
"build1",
|
||||||
|
);
|
||||||
|
expect(source.paths()).toEqual(["/app.js", "/index.html"]);
|
||||||
|
expect(source.etag("/app.js")).toBe(`W/"${BIG_JS.length.toString(16)}-build1"`);
|
||||||
|
const app = createM3App({ files: source });
|
||||||
|
expect(await (await get(app, "/")).text()).toContain("home");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encoding and port helpers", () => {
|
||||||
|
test("pickEncoding prefers br, then gzip", () => {
|
||||||
|
expect(pickEncoding("gzip, deflate, br")).toBe("br");
|
||||||
|
expect(pickEncoding("gzip")).toBe("gzip");
|
||||||
|
expect(pickEncoding("identity")).toBeUndefined();
|
||||||
|
expect(pickEncoding(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
test("resolvePort: PORT first, then the option, then 3000", () => {
|
||||||
|
const saved = Bun.env.PORT;
|
||||||
|
try {
|
||||||
|
delete Bun.env.PORT;
|
||||||
|
expect(resolvePort()).toBe(3000);
|
||||||
|
expect(resolvePort(4000)).toBe(4000);
|
||||||
|
Bun.env.PORT = "5000";
|
||||||
|
expect(resolvePort(4000)).toBe(5000);
|
||||||
|
Bun.env.PORT = "nope";
|
||||||
|
expect(resolvePort(4000)).toBe(4000);
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete Bun.env.PORT;
|
||||||
|
else Bun.env.PORT = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("one process, one socket: UI and API over real HTTP", () => {
|
||||||
|
test("Bun.serve answers native API routes and the file server from the same port", async () => {
|
||||||
|
const saved = Bun.env.PORT;
|
||||||
|
delete Bun.env.PORT;
|
||||||
|
const server = startM3Server({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
name: "real",
|
||||||
|
files: dirSource(dir),
|
||||||
|
api: {
|
||||||
|
"/api/echo/:word": (request: M3Request) => Response.json({ word: request.params.word }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const base = `http://127.0.0.1:${server.port}`;
|
||||||
|
expect(await (await fetch(`${base}/api/echo/hi`)).json()).toEqual({ word: "hi" });
|
||||||
|
const page = await fetch(base);
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(await page.text()).toContain("home");
|
||||||
|
expect(((await (await fetch(`${base}/healthz`)).json()) as { name: string }).name).toBe(
|
||||||
|
"real",
|
||||||
|
);
|
||||||
|
const asset = await fetch(`${base}/app.js`, { headers: { "accept-encoding": "br" } });
|
||||||
|
expect(asset.headers.get("content-encoding")).toBe("br");
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
if (saved !== undefined) Bun.env.PORT = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serving a build", () => {
|
||||||
|
test("an uncaught API error answers a plain 500, not the development error page", async () => {
|
||||||
|
const server = startM3Server({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
files: dirSource(dir),
|
||||||
|
api: {
|
||||||
|
"/api/boom": () => {
|
||||||
|
throw new Error("secret detail");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(server.development).toBe(false);
|
||||||
|
const res = await fetch(`http://127.0.0.1:${server.port}/api/boom`);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).not.toContain("secret detail");
|
||||||
|
expect(body).not.toContain("__bunfallback");
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// System tests: the generated images really build and run. Opt in with M3_BUN_SYSTEM=1 (they need docker,
|
||||||
|
// and for the boot test QEMU and a kernel in M3_BUN_KERNEL; they take about a minute and write ~300 MB).
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { compileApp } from "../src/compile.ts";
|
||||||
|
import { packageApp } from "../src/package.ts";
|
||||||
|
import { generateTargets, contextFor } from "../src/targets/index.ts";
|
||||||
|
import { loadApp } from "../src/config.ts";
|
||||||
|
import { cleanup, fixtureCopy, freePort } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const enabled = Bun.env.M3_BUN_SYSTEM === "1";
|
||||||
|
const sh = (cmd: string[], cwd?: string) => {
|
||||||
|
const proc = Bun.spawnSync(cmd, {
|
||||||
|
windowsHide: true,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: proc.exitCode ?? 1,
|
||||||
|
out: proc.stdout.toString().trim(),
|
||||||
|
err: proc.stderr.toString().trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function until(url: string, tries = 100): Promise<Response> {
|
||||||
|
let last: unknown;
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
try {
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Polls depend on the previous attempt and must stop at the first ready response.
|
||||||
|
return await fetch(url, { signal: AbortSignal.timeout(1500) });
|
||||||
|
} catch (error) {
|
||||||
|
last = error;
|
||||||
|
// oxlint-disable-next-line no-await-in-loop -- Back off only after the preceding readiness attempt fails.
|
||||||
|
await Bun.sleep(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`${url} never answered: ${String(last)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!enabled)("generated images run", () => {
|
||||||
|
test.skipIf(!Bun.which("docker"))(
|
||||||
|
"the scratch image builds from the rootfs, serves UI and API, and passes its own health check",
|
||||||
|
async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const app = await loadApp(dir);
|
||||||
|
const { outfile } = await compileApp({ app });
|
||||||
|
const out = join(dir, "release");
|
||||||
|
const report = await packageApp({ app, executable: outfile, out, rootfs: true });
|
||||||
|
expect(report.rootfs?.libraries.length).toBeGreaterThanOrEqual(4);
|
||||||
|
await generateTargets(["docker"], contextFor(app), join(dir, "deploy"));
|
||||||
|
writeFileSync(
|
||||||
|
join(out, "Dockerfile"),
|
||||||
|
await Bun.file(join(dir, "deploy/docker/Dockerfile.scratch")).text(),
|
||||||
|
);
|
||||||
|
const tag = `m3-bun-system-test/${app.name}:${Date.now()}`;
|
||||||
|
const name = `m3-bun-system-${Date.now()}`;
|
||||||
|
const built = sh(["docker", "build", "-q", "-t", tag, out]);
|
||||||
|
expect(built.code).toBe(0);
|
||||||
|
const port = freePort();
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
sh([
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"-p",
|
||||||
|
`127.0.0.1:${port}:${app.port}`,
|
||||||
|
"--read-only",
|
||||||
|
"--cap-drop",
|
||||||
|
"ALL",
|
||||||
|
"--security-opt",
|
||||||
|
"no-new-privileges",
|
||||||
|
tag,
|
||||||
|
]).code,
|
||||||
|
).toBe(0);
|
||||||
|
const health = await until(`http://127.0.0.1:${port}/healthz`);
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
expect(await (await fetch(`http://127.0.0.1:${port}/api/hello/docker`)).json()).toEqual({
|
||||||
|
hello: "docker",
|
||||||
|
});
|
||||||
|
expect((await fetch(`http://127.0.0.1:${port}/`)).headers.get("content-type")).toStartWith(
|
||||||
|
"text/html",
|
||||||
|
);
|
||||||
|
expect(sh(["docker", "exec", name, "/app/server", "--healthcheck"]).code).toBe(0);
|
||||||
|
const user = sh(["docker", "inspect", name, "--format", "{{.Config.User}}"]).out;
|
||||||
|
expect(user).toBe("10001:10001");
|
||||||
|
} finally {
|
||||||
|
sh(["docker", "rm", "-f", name]);
|
||||||
|
sh(["docker", "rmi", "-f", tag]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
240_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(
|
||||||
|
!Bun.which("qemu-system-x86_64") ||
|
||||||
|
!Bun.env.M3_BUN_KERNEL ||
|
||||||
|
!existsSync(Bun.env.M3_BUN_KERNEL ?? ""),
|
||||||
|
)(
|
||||||
|
"the minimal Linux image boots in QEMU and serves over the forwarded port",
|
||||||
|
async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const app = await loadApp(dir);
|
||||||
|
const { outfile } = await compileApp({ app });
|
||||||
|
await generateTargets(["linux-image"], contextFor(app), join(dir, "deploy"));
|
||||||
|
const build = sh([
|
||||||
|
"bash",
|
||||||
|
join(dir, "deploy/linux-image/build-image.sh"),
|
||||||
|
outfile,
|
||||||
|
join(dir, "image"),
|
||||||
|
]);
|
||||||
|
expect(build.code).toBe(0);
|
||||||
|
const port = freePort();
|
||||||
|
const qemu = Bun.spawn(
|
||||||
|
["bash", join(dir, "deploy/linux-image/run-qemu.sh"), join(dir, "image/initramfs.cpio.gz")],
|
||||||
|
{
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, KERNEL: Bun.env.M3_BUN_KERNEL!, HOST_PORT: String(port) },
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const health = await until(`http://127.0.0.1:${port}/healthz`, 300);
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
expect(await (await fetch(`http://127.0.0.1:${port}/api/hello/qemu`)).json()).toEqual({
|
||||||
|
hello: "qemu",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
qemu.kill();
|
||||||
|
await qemu.exited;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
240_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!enabled)("cross-compiled executables", () => {
|
||||||
|
test("windows-x64 is a PE executable named .exe, linux-x64-musl links musl and libstdc++ (dynamic: the documented limit)", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
const app = await loadApp(dir);
|
||||||
|
const win = await compileApp({ app, target: "windows-x64" });
|
||||||
|
expect(win.outfile.endsWith("hello-m3-windows-x64.exe")).toBe(true);
|
||||||
|
const head = new Uint8Array(await Bun.file(win.outfile).slice(0, 2).arrayBuffer());
|
||||||
|
expect(String.fromCharCode(...head)).toBe("MZ");
|
||||||
|
expect(win.bytes).toBeGreaterThan(50_000_000);
|
||||||
|
|
||||||
|
const musl = await compileApp({ app, target: "linux-x64-musl", build: false });
|
||||||
|
const elf = sh(["readelf", "-d", "-l", musl.outfile]);
|
||||||
|
expect(elf.out).toContain("/lib/ld-musl-x86_64.so.1");
|
||||||
|
expect(elf.out).toContain("libstdc++.so.6");
|
||||||
|
expect(elf.out).toContain("libc.musl-x86_64.so.1");
|
||||||
|
const glibc = await compileApp({ app, target: "linux-x64", build: false });
|
||||||
|
const needed = sh(["readelf", "-d", glibc.outfile]).out;
|
||||||
|
for (const lib of ["libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2"])
|
||||||
|
expect(needed).toContain(lib);
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("Windows metadata is only applied on Windows: elsewhere the report says so instead of pretending", async () => {
|
||||||
|
const dir = fixtureCopy();
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "m3.config.ts"),
|
||||||
|
`import { defineM3Config } from "@aphrody/m3/config";
|
||||||
|
import { defineM3Bun } from "../../../src/config.ts";
|
||||||
|
export default defineM3Config({ entry: "src/index.html", theme: { seed: "#6750a4" } });
|
||||||
|
export const bun = defineM3Bun({ api: "src/api.ts", compile: { windows: { title: "Hello M3", publisher: "Aphrody", version: "1.2.3.0" } } });
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const app = await loadApp(dir);
|
||||||
|
const win = await compileApp({ app, target: "windows-x64" });
|
||||||
|
if (process.platform === "win32") expect(win.warnings).toEqual([]);
|
||||||
|
else expect(win.warnings.join(" ")).toContain("only applied when compiling on Windows");
|
||||||
|
const plain = await compileApp({
|
||||||
|
app: await loadApp(fixtureCopy()),
|
||||||
|
target: "linux-x64",
|
||||||
|
build: true,
|
||||||
|
});
|
||||||
|
expect(plain.warnings).toEqual([]);
|
||||||
|
}, 240_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system tests are opt-in", () => {
|
||||||
|
expect(typeof enabled).toBe("boolean");
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Cloud and release targets: lambda (the bun-lambda runtime of the Bun fork), release (GitHub workflow on the
|
||||||
|
// fork's install.sh), next (@aphrody/m3-next inside @aphrody/next-bun).
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { loadApp } from "../src/config.ts";
|
||||||
|
import { contextFor, generateTargets, type TargetContext } from "../src/targets/index.ts";
|
||||||
|
import { forkTag, RELEASE_MATRIX } from "../src/targets/release.ts";
|
||||||
|
import { COMPILE_TARGETS } from "../src/config.ts";
|
||||||
|
import { cleanup, fixtureCopy } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
let app = "";
|
||||||
|
let ctx: TargetContext;
|
||||||
|
let out = "";
|
||||||
|
const read = (id: string, file: string) => Bun.file(join(out, id, file)).text();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = fixtureCopy();
|
||||||
|
out = join(app, "deploy");
|
||||||
|
ctx = contextFor(await loadApp(app));
|
||||||
|
await generateTargets(["lambda", "release", "next"], ctx, out);
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
/** Bundle the generated handler for Bun, with `@aphrody/m3-bun/*` taken from this checkout. */
|
||||||
|
async function bundleHandler(outdir: string): Promise<string> {
|
||||||
|
const src = resolve(import.meta.dir, "../src");
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [join(out, "lambda", "handler.ts")],
|
||||||
|
outdir,
|
||||||
|
target: "bun",
|
||||||
|
naming: "handler.js",
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "m3-bun-self",
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^@aphrody\/m3-bun\/server$/ }, () => ({
|
||||||
|
path: join(src, "server.ts"),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.logs).toEqual([]);
|
||||||
|
mkdirSync(join(outdir, "dist"), { recursive: true });
|
||||||
|
writeFileSync(join(outdir, "dist", "index.html"), "<!doctype html><title>hello-m3</title>");
|
||||||
|
return join(outdir, "handler.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lambda", () => {
|
||||||
|
test("handler.ts is createM3App for _HANDLER=handler.fetch: UI, API and /healthz from one fetch", async () => {
|
||||||
|
const handler = await read("lambda", "handler.ts");
|
||||||
|
expect(handler).toContain('import api from "../../src/api.ts";');
|
||||||
|
const bundled = await bundleHandler(join(out, "lambda", "out"));
|
||||||
|
const { default: lambda } = (await import(bundled)) as {
|
||||||
|
default: { fetch(r: Request): Promise<Response> };
|
||||||
|
};
|
||||||
|
const [health, hello, page] = await Promise.all([
|
||||||
|
lambda.fetch(new Request("https://fn.lambda-url.eu-west-3.on.aws/healthz")),
|
||||||
|
lambda.fetch(new Request("https://fn.lambda-url.eu-west-3.on.aws/api/hello/lambda")),
|
||||||
|
lambda.fetch(
|
||||||
|
new Request("https://fn.lambda-url.eu-west-3.on.aws/", {
|
||||||
|
headers: { Accept: "text/html" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect([health.status, await hello.json(), await page.text()]).toEqual([
|
||||||
|
200,
|
||||||
|
{ hello: "lambda" },
|
||||||
|
"<!doctype html><title>hello-m3</title>",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("build script, layer script on bun-lambda build-layer and a SAM template for provided.al2023", async () => {
|
||||||
|
const build = await read("lambda", "build-lambda.ts");
|
||||||
|
expect(build).toContain('import { archiveDir } from "@aphrody/m3-bun";');
|
||||||
|
expect(build).toContain('const app = join(here, "../..");');
|
||||||
|
expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(build)).not.toThrow();
|
||||||
|
const layer = await read("lambda", "build-layer.sh");
|
||||||
|
expect(layer).toContain('/packages/bun-lambda/scripts/build-layer.ts" --arch aarch64 \\\n');
|
||||||
|
const tag = forkTag(ctx.bunVersion);
|
||||||
|
expect(layer).toContain(
|
||||||
|
tag
|
||||||
|
? `/releases/download/${tag}/bun-linux-aarch64.zip`
|
||||||
|
: `--release "bun-v${ctx.bunVersion}"`,
|
||||||
|
);
|
||||||
|
const sam = Bun.YAML.parse(await read("lambda", "template.yaml")) as any;
|
||||||
|
expect(sam.Resources.App.Properties).toMatchObject({
|
||||||
|
Handler: "handler.fetch",
|
||||||
|
Runtime: "provided.al2023",
|
||||||
|
Architectures: ["arm64"],
|
||||||
|
CodeUri: "function.zip",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bun-lambda runtime of the fork against a local Lambda Runtime API: one function URL event in, one response out.
|
||||||
|
const runtime = join(
|
||||||
|
process.env.APHRODY_BUN_CHECKOUT ?? "C:/bun",
|
||||||
|
"packages/bun-lambda/runtime.ts",
|
||||||
|
);
|
||||||
|
const runnable =
|
||||||
|
existsSync(runtime) &&
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
Bun.resolveSync("aws4fetch", dirname(runtime));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
test.skipIf(!runnable)(
|
||||||
|
"the bun-lambda runtime serves the bundled handler",
|
||||||
|
async () => {
|
||||||
|
const task = join(out, "lambda", "task");
|
||||||
|
await bundleHandler(task);
|
||||||
|
const { promise: answered, resolve: answer } = Promise.withResolvers<{
|
||||||
|
statusCode: number;
|
||||||
|
body: string;
|
||||||
|
}>();
|
||||||
|
let served = false;
|
||||||
|
await using api = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
routes: {
|
||||||
|
"/2018-06-01/runtime/invocation/next": () => {
|
||||||
|
if (served) return new Promise<Response>(() => {});
|
||||||
|
served = true;
|
||||||
|
const event = {
|
||||||
|
version: "2.0",
|
||||||
|
requestContext: {
|
||||||
|
requestId: "r1",
|
||||||
|
domainName: "fn.example",
|
||||||
|
http: { method: "GET", path: "/api/hello/aws" },
|
||||||
|
},
|
||||||
|
headers: { host: "fn.example" },
|
||||||
|
isBase64Encoded: false,
|
||||||
|
};
|
||||||
|
return Response.json(event, {
|
||||||
|
headers: {
|
||||||
|
"Lambda-Runtime-Aws-Request-Id": "r1",
|
||||||
|
"Lambda-Runtime-Trace-Id": "t1",
|
||||||
|
"Lambda-Runtime-Invoked-Function-Arn":
|
||||||
|
"arn:aws:lambda:eu-west-3:000000000000:function:hello-m3",
|
||||||
|
"Lambda-Runtime-Deadline-Ms": String(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"/2018-06-01/runtime/invocation/r1/response": {
|
||||||
|
POST: async (request) => {
|
||||||
|
answer(await request.json());
|
||||||
|
return new Response(null, { status: 202 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await using proc = Bun.spawn({
|
||||||
|
windowsHide: true,
|
||||||
|
cmd: [process.execPath, runtime],
|
||||||
|
cwd: task,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AWS_LAMBDA_RUNTIME_API: `127.0.0.1:${api.port}`,
|
||||||
|
_HANDLER: "handler.fetch",
|
||||||
|
LAMBDA_TASK_ROOT: task,
|
||||||
|
},
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
});
|
||||||
|
const response = await Promise.race([
|
||||||
|
answered,
|
||||||
|
proc.exited.then((code) =>
|
||||||
|
Promise.reject(new Error(`bun-lambda runtime exited with ${code}`)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
proc.kill();
|
||||||
|
expect({ status: response.statusCode, body: JSON.parse(response.body) }).toEqual({
|
||||||
|
status: 200,
|
||||||
|
body: { hello: "aws" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("release", () => {
|
||||||
|
test("a tag builds every target with the fork Bun and attaches archives and checksums to the release", async () => {
|
||||||
|
const workflow = Bun.YAML.parse(await read("release", ".github/workflows/release.yml")) as any;
|
||||||
|
expect(workflow.on.push.tags).toEqual(["v*"]);
|
||||||
|
expect(workflow.permissions).toEqual({ contents: "write" });
|
||||||
|
const include = workflow.jobs.package.strategy.matrix.include as { target: string }[];
|
||||||
|
expect(include.map((m) => m.target)).toEqual(RELEASE_MATRIX.map((m) => m.target));
|
||||||
|
for (const { target } of include)
|
||||||
|
expect(COMPILE_TARGETS as readonly string[]).toContain(target);
|
||||||
|
const steps = workflow.jobs.package.steps.map((s: any) => s.run ?? s.uses).join("\n");
|
||||||
|
expect(steps).toContain("aphrody-labs/bun/main/scripts/aphrody/install.sh | bash -s ");
|
||||||
|
expect(steps).toContain(
|
||||||
|
"bunx --bun m3-bun package --target ${{ matrix.target }} --out release",
|
||||||
|
);
|
||||||
|
const publish = workflow.jobs.publish.steps.map((s: any) => s.run ?? s.uses).join("\n");
|
||||||
|
expect(publish).toContain('gh release create "$GITHUB_REF_NAME" release/*');
|
||||||
|
expect(publish).toContain("grep -v '\\.sha256$'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forkTag names the fork release of a version", () => {
|
||||||
|
expect(forkTag("1.4.3-aphrody.2")).toBe("aphrody-v1.4.3-aphrody.2");
|
||||||
|
expect(forkTag("0.0.1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("next", () => {
|
||||||
|
test("next.config.ts is withBun(withM3(...)): standalone output, M3 packages transpiled, Turbopack root set", async () => {
|
||||||
|
// Evaluated inside this package so @aphrody/m3-next and @aphrody/next-bun resolve.
|
||||||
|
const { default: config } = (await import(join(out, "next", "next.config.ts"))) as {
|
||||||
|
default: (phase: string, ctx: { defaultConfig: object }) => Promise<Record<string, any>>;
|
||||||
|
};
|
||||||
|
const resolved = await config("phase-production-build", { defaultConfig: {} });
|
||||||
|
expect(resolved).toMatchObject({
|
||||||
|
output: "standalone",
|
||||||
|
poweredByHeader: false,
|
||||||
|
env: { M3_APP_VERSION: "1.2.3" },
|
||||||
|
});
|
||||||
|
expect(resolved.transpilePackages).toContain("@aphrody/m3-next");
|
||||||
|
expect(typeof resolved.turbopack.root).toBe("string");
|
||||||
|
const build = await read("next", "build-next.sh");
|
||||||
|
expect(build).toContain("bunx --bun next-bun build --turbopack");
|
||||||
|
expect(build).toContain("bunx --bun next-bun standalone .next/standalone");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Desktop and OS targets: tauri (@aphrody/web-to-tauri, @aphrody/scaffold), webos (Bun.WebView,
|
||||||
|
// @aphrody/bun-webview-page), windows (icon through m3-svg-to-ico, bun:windows inspection), os-themes.
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { loadApp } from "../src/config.ts";
|
||||||
|
import {
|
||||||
|
contextFor,
|
||||||
|
generateTargets,
|
||||||
|
inspectWindows,
|
||||||
|
verifyWebView,
|
||||||
|
windowsIcon,
|
||||||
|
type TargetContext,
|
||||||
|
} from "../src/targets/index.ts";
|
||||||
|
import { iconFile, pngSvg } from "../src/targets/windows.ts";
|
||||||
|
import { cleanup, FIXTURE, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
// `bun:windows` exists only in the aphrody-labs fork (run with C:/bun/build/debug/bun-debug.exe).
|
||||||
|
const bunWindows = await import("bun:windows" as string).then(
|
||||||
|
(m: { default?: { isSupported?: boolean } }) => m.default?.isSupported === true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ctx: TargetContext;
|
||||||
|
let out = "";
|
||||||
|
const read = (id: string, file: string) => readFileSync(join(out, id, file), "utf8");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
out = tmp("m3-bun-desktop-");
|
||||||
|
ctx = contextFor(await loadApp(FIXTURE));
|
||||||
|
await generateTargets(["tauri", "webos", "windows", "os-themes"], ctx, out);
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
describe("tauri", () => {
|
||||||
|
test("web-to-tauri plans a sidecar project on the scaffold pins; the compiled app is the sidecar", async () => {
|
||||||
|
const { TAURI_3 } = await import("@aphrody/scaffold/pins");
|
||||||
|
const conf = JSON.parse(read("tauri", "src-tauri/tauri.conf.json"));
|
||||||
|
expect(conf).toMatchObject({
|
||||||
|
productName: "hello-m3",
|
||||||
|
identifier: "io.aphrody.app.hello-m3",
|
||||||
|
version: "1.2.3",
|
||||||
|
});
|
||||||
|
expect(conf.bundle.externalBin).toEqual(["binaries/server"]);
|
||||||
|
const wrap = JSON.parse(read("tauri", "src-tauri/wrap.json"));
|
||||||
|
expect(wrap).toMatchObject({
|
||||||
|
mode: "sidecar",
|
||||||
|
sidecar: { program: "server", port: 3456, readyPath: "/healthz" },
|
||||||
|
});
|
||||||
|
const pkg = JSON.parse(read("tauri", "package.json"));
|
||||||
|
expect(pkg.devDependencies["@tauri-apps/cli"]).toBe(TAURI_3.jsCli);
|
||||||
|
expect(pkg.scripts["build:sidecar"]).toBe("bun scripts/build-sidecar.ts");
|
||||||
|
const cargo = read("tauri", "src-tauri/Cargo.toml");
|
||||||
|
expect(cargo).toContain(`tauri = { version = "=${TAURI_3.tauri}"`);
|
||||||
|
const crate = /aphrody-tauri-wrap = \{ path = "([^"]+)"/.exec(cargo)![1]!;
|
||||||
|
expect(existsSync(join(resolve(out, "tauri/src-tauri", crate), "Cargo.toml"))).toBe(true);
|
||||||
|
const sidecar = read("tauri", "scripts/build-sidecar.ts");
|
||||||
|
expect(sidecar).toContain("/host: (\\S+)/");
|
||||||
|
expect(sidecar).toContain('"compile", "--outfile"');
|
||||||
|
expect(new Bun.Transpiler({ loader: "ts" }).scan(sidecar).imports.map((i) => i.path)).toEqual([
|
||||||
|
"node:path",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("webos", () => {
|
||||||
|
test("desktop.ts opens the compiled app in a Bun.WebView window and smoke-checks it with verifyWebView", () => {
|
||||||
|
const desktop = read("webos", "desktop.ts");
|
||||||
|
expect(desktop).toContain('backend: { type: "chrome", url: false }');
|
||||||
|
expect(desktop).toContain("headless: false");
|
||||||
|
expect(desktop).toContain('"Target.detachedFromTarget"');
|
||||||
|
expect(desktop).toContain('import("@aphrody/m3-bun/targets")');
|
||||||
|
expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(desktop)).not.toThrow();
|
||||||
|
expect(read("webos", "README.md")).toContain("no external-app manifest");
|
||||||
|
});
|
||||||
|
|
||||||
|
const chrome =
|
||||||
|
process.env.BUN_CHROME_PATH ??
|
||||||
|
Bun.which("chrome") ??
|
||||||
|
Bun.which("chromium") ??
|
||||||
|
Bun.which("google-chrome") ??
|
||||||
|
[
|
||||||
|
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||||
|
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
||||||
|
].find(existsSync);
|
||||||
|
test.skipIf(!chrome || typeof Bun.WebView !== "function")(
|
||||||
|
"verifyWebView loads a page through @aphrody/bun-webview-page and reports title, health and console errors",
|
||||||
|
async () => {
|
||||||
|
await using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
routes: {
|
||||||
|
"/healthz": new Response("ok"),
|
||||||
|
"/": new Response(
|
||||||
|
'<!doctype html><title>Hello M3</title><script>console.error("boom")</script>',
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const report = await verifyWebView(`http://127.0.0.1:${server.port}/`, { screenshot: true });
|
||||||
|
expect({ ...report, screenshot: report.screenshot?.slice(0, 4) }).toMatchObject({
|
||||||
|
title: "Hello M3",
|
||||||
|
status: 200,
|
||||||
|
healthy: true,
|
||||||
|
errors: ["boom"],
|
||||||
|
ok: false,
|
||||||
|
screenshot: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("windows", () => {
|
||||||
|
test("the default icon is the @aphrody/identity emblem as a square SVG; a configured .ico is taken as is", async () => {
|
||||||
|
const svg = read("windows", "icon.svg");
|
||||||
|
expect(svg).toStartWith('<svg xmlns="http://www.w3.org/2000/svg"');
|
||||||
|
expect(svg).toContain('width="192" height="192"');
|
||||||
|
const png = Buffer.from(/base64,([^"]+)"/.exec(svg)![1]!, "base64");
|
||||||
|
expect([...png.subarray(1, 4)]).toEqual([0x50, 0x4e, 0x47]);
|
||||||
|
expect(pngSvg(new Uint8Array([1]), 8)).toContain('viewBox="0 0 8 8"');
|
||||||
|
const dir = tmp();
|
||||||
|
await Bun.write(join(dir, "app.ico"), new Uint8Array([0, 0, 1, 0]));
|
||||||
|
const ico = await iconFile({ ...ctx, appDir: dir, windows: { icon: "app.ico" } });
|
||||||
|
expect(ico).toEqual({ path: "icons/icon.ico", content: new Uint8Array([0, 0, 1, 0]) });
|
||||||
|
await expect(iconFile({ ...ctx, appDir: dir, windows: { icon: "app.png" } })).rejects.toThrow(
|
||||||
|
/does not exist/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tauriCli = Bun.which("tauri");
|
||||||
|
test.skipIf(!tauriCli)(
|
||||||
|
"windowsIcon turns the generated icon.svg into icon.ico through m3-svg-to-ico",
|
||||||
|
() => {
|
||||||
|
const dir = tmp();
|
||||||
|
const ico = windowsIcon(join(out, "windows", "icon.svg"), join(dir, "icons"));
|
||||||
|
const bytes = readFileSync(ico);
|
||||||
|
expect([...bytes.subarray(0, 4)]).toEqual([0, 0, 1, 0]);
|
||||||
|
expect(bytes.readUInt16LE(4)).toBeGreaterThan(1);
|
||||||
|
expect(existsSync(join(dir, "icons", "icon.icns"))).toBe(true);
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!bunWindows)(
|
||||||
|
"inspectWindows reads shortcuts, the uninstall entry and the service without writing",
|
||||||
|
async () => {
|
||||||
|
const report = await inspectWindows({
|
||||||
|
name: `m3-bun-absent-${process.pid}`,
|
||||||
|
version: "1.2.3",
|
||||||
|
});
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
executable: { exists: false },
|
||||||
|
shortcuts: { startMenu: { exists: false }, desktop: { exists: false } },
|
||||||
|
uninstall: null,
|
||||||
|
service: null,
|
||||||
|
upToDate: false,
|
||||||
|
});
|
||||||
|
expect(report.shortcuts.startMenu.path).toEndWith(
|
||||||
|
join("Programs", `m3-bun-absent-${process.pid}.lnk`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test.skipIf(bunWindows)("inspectWindows names the runtime it needs", async () => {
|
||||||
|
await expect(inspectWindows({ name: "x", version: "1" })).rejects.toThrow(
|
||||||
|
/bun:windows|Windows only/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("os-themes", () => {
|
||||||
|
test("@aphrody/m3-os-themes files for every desktop, installers executable, README lists their commands", () => {
|
||||||
|
for (const file of [
|
||||||
|
"windows/M3-hello-m3-light.theme",
|
||||||
|
"windows/install-windows.ps1",
|
||||||
|
"gnome/install-gnome.sh",
|
||||||
|
"cosmic/install-cosmic.sh",
|
||||||
|
"chromium/install-chromium.sh",
|
||||||
|
])
|
||||||
|
expect(existsSync(join(out, "os-themes", file))).toBe(true);
|
||||||
|
const wallpaper = readFileSync(join(out, "os-themes", "windows/wallpaper-hello-m3-dark.png"));
|
||||||
|
expect([...wallpaper.subarray(1, 4)]).toEqual([0x50, 0x4e, 0x47]);
|
||||||
|
if (process.platform !== "win32")
|
||||||
|
expect(statSync(join(out, "os-themes", "gnome/install-gnome.sh")).mode & 0o111).not.toBe(0);
|
||||||
|
const readme = read("os-themes", "README.md");
|
||||||
|
expect(readme).toContain("seed `#6750a4`");
|
||||||
|
expect(readme).toContain("`bash gnome/install-gnome.sh --mode auto`");
|
||||||
|
expect(readme).toContain("windows/install-windows.ps1 -Mode auto");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { loadApp } from "../src/config.ts";
|
||||||
|
import {
|
||||||
|
generateTargets,
|
||||||
|
getTarget,
|
||||||
|
TARGETS,
|
||||||
|
contextFor,
|
||||||
|
type TargetContext,
|
||||||
|
} from "../src/targets/index.ts";
|
||||||
|
import { RUNTIME_IMAGE } from "../src/targets/docker.ts";
|
||||||
|
import {
|
||||||
|
httpVhostModel,
|
||||||
|
nginxRenderer,
|
||||||
|
renderNginx,
|
||||||
|
vhostModel,
|
||||||
|
type NginxModel,
|
||||||
|
} from "../src/targets/nginx.ts";
|
||||||
|
import { sharedDropin } from "../src/targets/systemd.ts";
|
||||||
|
import { cleanup, FIXTURE, freePort, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
let ctx: TargetContext;
|
||||||
|
let out = "";
|
||||||
|
const read = (id: string, file: string) => readFileSync(join(out, id, file), "utf8");
|
||||||
|
const json = (id: string, file: string) => JSON.parse(read(id, file)) as Record<string, any>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
out = tmp("m3-bun-targets-");
|
||||||
|
ctx = contextFor(await loadApp(FIXTURE));
|
||||||
|
await generateTargets(["all"], ctx, out);
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
describe("the context", () => {
|
||||||
|
test("is derived from the app and its deploy section", () => {
|
||||||
|
expect(ctx).toMatchObject({
|
||||||
|
name: "hello-m3",
|
||||||
|
version: "1.2.3",
|
||||||
|
port: 3456,
|
||||||
|
domain: "hello.example.com",
|
||||||
|
image: "ghcr.io/aphrody-labs/hello-m3",
|
||||||
|
namespace: "aphrody",
|
||||||
|
user: "ubuntu",
|
||||||
|
region: "cdg",
|
||||||
|
binary: "hello-m3",
|
||||||
|
outdir: "dist",
|
||||||
|
api: "src/api.ts",
|
||||||
|
bunVersion: Bun.version,
|
||||||
|
tls: "letsencrypt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registry", () => {
|
||||||
|
test("sixteen targets with unique ids, each with a description; an unknown id lists the known ones", () => {
|
||||||
|
expect(TARGETS.map((t) => t.id)).toEqual([
|
||||||
|
"docker",
|
||||||
|
"systemd",
|
||||||
|
"nginx",
|
||||||
|
"vercel",
|
||||||
|
"fly",
|
||||||
|
"cloudflare",
|
||||||
|
"coolify",
|
||||||
|
"vscode",
|
||||||
|
"windows",
|
||||||
|
"linux-image",
|
||||||
|
"tauri",
|
||||||
|
"webos",
|
||||||
|
"lambda",
|
||||||
|
"os-themes",
|
||||||
|
"release",
|
||||||
|
"next",
|
||||||
|
]);
|
||||||
|
for (const t of TARGETS) expect(t.description.length).toBeGreaterThan(20);
|
||||||
|
expect(() => getTarget("heroku")).toThrow(/unknown deploy target heroku \(docker, systemd/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generation writes only below <out>/<id>/ and every target produced files", async () => {
|
||||||
|
for (const t of TARGETS) expect(existsSync(join(out, t.id))).toBe(true);
|
||||||
|
const only = tmp();
|
||||||
|
const generated = await generateTargets(["fly"], ctx, only);
|
||||||
|
expect(generated.map((g) => g.id)).toEqual(["fly"]);
|
||||||
|
expect(existsSync(join(only, "docker"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a generated path that escapes its directory is refused", async () => {
|
||||||
|
const { writeFiles } = await import("../src/util.ts");
|
||||||
|
await expect(writeFiles(tmp(), [{ path: "../escape.txt", content: "x" }])).rejects.toThrow(
|
||||||
|
/escapes its directory/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docker", () => {
|
||||||
|
test("multi-stage Dockerfile: compile and run on the Aphrody Alpine runtime image, healthcheck without a shell", () => {
|
||||||
|
const file = read("docker", "Dockerfile");
|
||||||
|
expect(RUNTIME_IMAGE).toBe("ghcr.io/aphrody-labs/alpine:3.24-runtime");
|
||||||
|
expect(file.match(/^FROM .*$/gm)).toEqual([
|
||||||
|
`FROM ${RUNTIME_IMAGE} AS build`,
|
||||||
|
`FROM ${RUNTIME_IMAGE}`,
|
||||||
|
]);
|
||||||
|
expect(file).toContain("COPY --chown=agent:agent . .");
|
||||||
|
expect(file).toContain("compile --outfile /home/agent/out/hello-m3");
|
||||||
|
expect(file).toContain("COPY --from=build /home/agent/out/hello-m3 /app/server");
|
||||||
|
expect(file).toContain(
|
||||||
|
'HEALTHCHECK --interval=30s --timeout=4s --start-period=5s --retries=3 CMD ["/app/server", "--healthcheck"]',
|
||||||
|
);
|
||||||
|
expect(file).toContain('ENTRYPOINT ["/app/server"]');
|
||||||
|
expect(file).not.toContain("USER root");
|
||||||
|
});
|
||||||
|
test("Dockerfile.scratch consumes the rootfs of `m3-bun package --rootfs`; compose is read-only with dropped capabilities", () => {
|
||||||
|
expect(read("docker", "Dockerfile.scratch")).toContain("COPY rootfs/ /");
|
||||||
|
const compose = Bun.YAML.parse(read("docker", "docker-compose.yml")) as {
|
||||||
|
services: Record<string, any>;
|
||||||
|
};
|
||||||
|
const service = compose.services["hello-m3"];
|
||||||
|
expect(service.read_only).toBe(true);
|
||||||
|
expect(service.cap_drop).toEqual(["ALL"]);
|
||||||
|
expect(service.ports).toEqual(["127.0.0.1:3456:3456"]);
|
||||||
|
expect(service.healthcheck.test).toEqual(["CMD", "/app/server", "--healthcheck"]);
|
||||||
|
expect(read("docker", ".dockerignore")).toContain("node_modules");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("systemd", () => {
|
||||||
|
test("the unit has the shape of the yolo host units", () => {
|
||||||
|
const unit = read("systemd", "hello-m3.service");
|
||||||
|
expect(unit).toContain("[Unit]");
|
||||||
|
for (const line of [
|
||||||
|
"Type=simple",
|
||||||
|
"User=ubuntu",
|
||||||
|
"Group=ubuntu",
|
||||||
|
"WorkingDirectory=/home/ubuntu/apps/aphrody/current",
|
||||||
|
"Environment=PORT=3456",
|
||||||
|
"EnvironmentFile=-/etc/aphrody/hello-m3.env",
|
||||||
|
"ExecStart=/home/ubuntu/apps/aphrody/current/bin/hello-m3",
|
||||||
|
"Restart=on-failure",
|
||||||
|
"RestartSec=2",
|
||||||
|
"WantedBy=multi-user.target",
|
||||||
|
])
|
||||||
|
expect(unit).toContain(line);
|
||||||
|
});
|
||||||
|
test("the sandbox is the shared drop-ins, byte for byte from the yolo checkout when it is there", () => {
|
||||||
|
const names = [
|
||||||
|
"sandbox-base.conf",
|
||||||
|
"sandbox-home-ro.conf",
|
||||||
|
"sandbox-kernel.conf",
|
||||||
|
"net-inet-only.conf",
|
||||||
|
"syscall-service.conf",
|
||||||
|
];
|
||||||
|
names.forEach((name, i) => {
|
||||||
|
expect(
|
||||||
|
read("systemd", `hello-m3.service.d/${String(10 + i * 10).padStart(2, "0")}-${name}`),
|
||||||
|
).toBe(sharedDropin(ctx.yoloRoot, name));
|
||||||
|
});
|
||||||
|
expect(sharedDropin("/nonexistent", "sandbox-base.conf")).toContain("ProtectSystem=strict");
|
||||||
|
if (existsSync(join(ctx.yoloRoot, "tools/config/host/systemd/shared/sandbox-base.conf"))) {
|
||||||
|
expect(sharedDropin(ctx.yoloRoot, "sandbox-base.conf")).toBe(
|
||||||
|
readFileSync(
|
||||||
|
join(ctx.yoloRoot, "tools/config/host/systemd/shared/sandbox-base.conf"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("the catalog entry follows service-catalog.json and the env file holds no secrets", () => {
|
||||||
|
expect(json("systemd", "service-catalog.entry.json")).toEqual({
|
||||||
|
id: "hello-m3",
|
||||||
|
application: "aphrody",
|
||||||
|
kind: "bun-http",
|
||||||
|
runtime: "bun",
|
||||||
|
unit: "hello-m3.service",
|
||||||
|
health: "http://127.0.0.1:3456/healthz",
|
||||||
|
port: 3456,
|
||||||
|
sourceRepo: "aphrody",
|
||||||
|
workingDirectory: "/home/ubuntu/apps/aphrody/current",
|
||||||
|
environmentFile: "/etc/aphrody/hello-m3.env",
|
||||||
|
publicProxy: "nginx",
|
||||||
|
public: true,
|
||||||
|
});
|
||||||
|
expect(read("systemd", "hello-m3.env.example")).toContain("PORT=3456");
|
||||||
|
});
|
||||||
|
test.skipIf(!Bun.which("systemd-analyze"))(
|
||||||
|
"systemd-analyze verify accepts the unit merged with its drop-ins",
|
||||||
|
() => {
|
||||||
|
const root = tmp("m3-bun-systemd-");
|
||||||
|
const bin = join(root, "current/bin");
|
||||||
|
mkdirSync(bin, { recursive: true });
|
||||||
|
writeFileSync(join(bin, "hello-m3"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
// The drop-ins are plain `[Service]` sections: appended in order, they are what systemd merges.
|
||||||
|
const dropins = readdirSync(join(out, "systemd", "hello-m3.service.d"))
|
||||||
|
.toSorted()
|
||||||
|
.map((f) => readFileSync(join(out, "systemd", "hello-m3.service.d", f), "utf8"));
|
||||||
|
const merged = `${read("systemd", "hello-m3.service")
|
||||||
|
.replaceAll("/home/ubuntu/apps/aphrody/current", join(root, "current"))
|
||||||
|
.replace("[Install]", `${dropins.join("\n")}\n[Install]`)}`;
|
||||||
|
const file = join(root, "hello-m3.service");
|
||||||
|
writeFileSync(file, merged);
|
||||||
|
const proc = Bun.spawnSync(["systemd-analyze", "verify", file], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const text = `${proc.stdout.toString()}${proc.stderr.toString()}`;
|
||||||
|
// The machine running the test may lack the `ubuntu` account, the env file or the targets; no other finding is tolerated.
|
||||||
|
const findings = text
|
||||||
|
.split("\n")
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
l.trim() &&
|
||||||
|
!/Failed to resolve (user|group)|User .* not found|Group .* not found|ubuntu/i.test(l),
|
||||||
|
);
|
||||||
|
expect(findings).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nginx", () => {
|
||||||
|
const renderer = nginxRenderer();
|
||||||
|
test("renderer reads real Bun process streams and propagates diagnostics on failure", async () => {
|
||||||
|
const script = join(out, "stream-renderer.ts");
|
||||||
|
await Bun.write(
|
||||||
|
script,
|
||||||
|
'const model = await Bun.stdin.json(); console.error("renderer diagnostic"); console.log(JSON.stringify({ model, args: process.argv.slice(2) }));',
|
||||||
|
);
|
||||||
|
const model = vhostModel(ctx);
|
||||||
|
expect(
|
||||||
|
JSON.parse(await renderNginx([process.execPath, script], model, ["owner comment"])),
|
||||||
|
).toEqual({
|
||||||
|
model,
|
||||||
|
args: ["--comment", "owner comment", "-"],
|
||||||
|
});
|
||||||
|
await Bun.write(script, 'console.error("renderer denied"); process.exit(23);');
|
||||||
|
await expect(renderNginx([process.execPath, script], model, [])).rejects.toThrow(
|
||||||
|
"failed (23): renderer denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("vhost model: upstream with keepalive, ACME and redirect on :80, TLS on :443, the yolo proxy snippet", () => {
|
||||||
|
const model = json("nginx", "hello-m3.nginx.json") as NginxModel;
|
||||||
|
expect(model).toEqual(vhostModel(ctx));
|
||||||
|
expect(model.upstreams).toEqual([
|
||||||
|
{ name: "hello_m3", targets: [{ type: "Tcp", target: "127.0.0.1:3456" }], keepalive: 16 },
|
||||||
|
]);
|
||||||
|
const [redirect, tls] = model.servers;
|
||||||
|
expect(redirect!.listen_ports).toEqual(["80"]);
|
||||||
|
expect(redirect!.locations!.map((l) => l.path)).toEqual([
|
||||||
|
"^~ /.well-known/acme-challenge/",
|
||||||
|
"/",
|
||||||
|
]);
|
||||||
|
expect(redirect!.locations![1]!.custom_directives).toEqual([
|
||||||
|
"return 301 https://hello.example.com$request_uri",
|
||||||
|
]);
|
||||||
|
expect(tls!.listen_ports).toEqual(["443 ssl"]);
|
||||||
|
expect(tls!.server_names).toEqual(["hello.example.com"]);
|
||||||
|
expect(tls!.ssl_certificate).toBe("/etc/letsencrypt/live/hello.example.com/fullchain.pem");
|
||||||
|
expect(tls!.custom_directives).toContain("http2 on");
|
||||||
|
expect(tls!.custom_directives).toContain(
|
||||||
|
'add_header Strict-Transport-Security "max-age=31536000" always',
|
||||||
|
);
|
||||||
|
expect(tls!.locations![0]!.proxy_pass).toBe("http://hello_m3");
|
||||||
|
expect(tls!.locations![0]!.custom_directives).toContain("include snippets/proxy-client-ip.inc");
|
||||||
|
expect(json("nginx", "hello-m3.http.nginx.json")).toEqual(httpVhostModel(ctx));
|
||||||
|
});
|
||||||
|
test("the listen-http2 form is available for nginx older than 1.25.1", () => {
|
||||||
|
const tls = vhostModel({ ...ctx, http2: "listen" }).servers[1]!;
|
||||||
|
expect(tls.listen_ports).toEqual(["443 ssl http2"]);
|
||||||
|
expect(tls.custom_directives).not.toContain("http2 on");
|
||||||
|
});
|
||||||
|
test("the vhosts are rendered by aphrody infra nginx, or say how to render them", () => {
|
||||||
|
if (renderer) {
|
||||||
|
const conf = read("nginx", "hello-m3.conf");
|
||||||
|
expect(conf).toContain(
|
||||||
|
"upstream hello_m3 {\n server 127.0.0.1:3456;\n keepalive 16;\n}",
|
||||||
|
);
|
||||||
|
expect(conf).toContain("listen 443 ssl;\n server_name hello.example.com;");
|
||||||
|
expect(conf).toContain(" http2 on;\n");
|
||||||
|
expect(conf).toContain("return 301 https://hello.example.com$request_uri;");
|
||||||
|
expect(conf).toContain('proxy_set_header Connection "";');
|
||||||
|
expect(conf).toContain("include snippets/proxy-client-ip.inc;");
|
||||||
|
} else {
|
||||||
|
expect(read("nginx", "hello-m3.conf.todo")).toContain(
|
||||||
|
"aphrody infra nginx render hello-m3.nginx.json > hello-m3.conf",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("the routes.json entry matches the catalog shape", () => {
|
||||||
|
expect(json("nginx", "routes.entry.json")).toEqual({
|
||||||
|
host: "hello.example.com",
|
||||||
|
service: "hello-m3",
|
||||||
|
upstream: "127.0.0.1:3456",
|
||||||
|
probePath: "/healthz",
|
||||||
|
vhost: "nginx/aphrody/hello-m3.conf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.skipIf(!Bun.which("nginx") || !nginxRenderer())(
|
||||||
|
"nginx -t accepts the TLS vhost (self-signed test certificate) and the HTTP variant",
|
||||||
|
async () => {
|
||||||
|
const dir = tmp("m3-bun-nginx-");
|
||||||
|
mkdirSync(join(dir, "snippets"), { recursive: true });
|
||||||
|
const snippet = join(ctx.yoloRoot, "tools/config/host/nginx/snippets/proxy-client-ip.inc");
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "snippets/proxy-client-ip.inc"),
|
||||||
|
existsSync(snippet)
|
||||||
|
? readFileSync(snippet, "utf8")
|
||||||
|
: "proxy_set_header X-Real-IP $remote_addr;\nproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n",
|
||||||
|
);
|
||||||
|
let tls: TargetContext["tls"] = "letsencrypt";
|
||||||
|
if (Bun.which("openssl")) {
|
||||||
|
const cert = join(dir, "c.pem");
|
||||||
|
const key = join(dir, "k.pem");
|
||||||
|
const made = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"openssl",
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
key,
|
||||||
|
"-out",
|
||||||
|
cert,
|
||||||
|
"-subj",
|
||||||
|
"/CN=hello.example.com",
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
{ windowsHide: true, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
expect(made.exitCode).toBe(0);
|
||||||
|
tls = { cert, key };
|
||||||
|
}
|
||||||
|
const target = join(tmp(), "nginx");
|
||||||
|
// `http2 on;` needs nginx 1.25.1; older servers take the `listen ... http2` form.
|
||||||
|
const version = /nginx\/(\d+)\.(\d+)\.(\d+)/.exec(
|
||||||
|
Bun.spawnSync(["nginx", "-v"], { windowsHide: true, stderr: "pipe" }).stderr.toString(),
|
||||||
|
);
|
||||||
|
const modern = version
|
||||||
|
? [Number(version[1]), Number(version[2]), Number(version[3])]
|
||||||
|
: [0, 0, 0];
|
||||||
|
const supportsDirective =
|
||||||
|
modern[0]! > 1 ||
|
||||||
|
(modern[0] === 1 && (modern[1]! > 25 || (modern[1] === 25 && modern[2]! >= 1)));
|
||||||
|
await generateTargets(
|
||||||
|
["nginx"],
|
||||||
|
{ ...ctx, tls, http2: supportsDirective ? "directive" : "listen" },
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
const files =
|
||||||
|
tls === "letsencrypt" ? ["hello-m3.http.conf"] : ["hello-m3.conf", "hello-m3.http.conf"];
|
||||||
|
for (const file of files) {
|
||||||
|
// An unprivileged test cannot bind :80 and :443: move the listeners, keep every other line as generated.
|
||||||
|
const generatedPath = join(target, "nginx", file);
|
||||||
|
writeFileSync(
|
||||||
|
generatedPath,
|
||||||
|
readFileSync(generatedPath, "utf8")
|
||||||
|
.replace("listen 80;", `listen 127.0.0.1:${freePort()};`)
|
||||||
|
.replace("listen 443 ssl", `listen 127.0.0.1:${freePort()} ssl`),
|
||||||
|
);
|
||||||
|
const main = join(dir, `${file}.main.conf`);
|
||||||
|
writeFileSync(
|
||||||
|
main,
|
||||||
|
`pid ${dir}/nginx.pid;\nerror_log ${dir}/error.log;\nevents {}\nhttp {\n access_log off;\n client_body_temp_path ${dir}/body;\n proxy_temp_path ${dir}/proxy;\n fastcgi_temp_path ${dir}/fcgi;\n uwsgi_temp_path ${dir}/uwsgi;\n scgi_temp_path ${dir}/scgi;\n include ${join(target, "nginx", file)};\n}\n`,
|
||||||
|
);
|
||||||
|
const proc = Bun.spawnSync(["nginx", "-t", "-p", dir, "-c", main], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const text = `${proc.stdout.toString()}${proc.stderr.toString()}`;
|
||||||
|
expect({ file, ok: proc.exitCode === 0, text: proc.exitCode === 0 ? "" : text }).toEqual({
|
||||||
|
file,
|
||||||
|
ok: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vercel", () => {
|
||||||
|
test("vercel.json: Bun runtime, static output, API rewrite, SPA rewrite that spares assets and API, headers", () => {
|
||||||
|
const v = json("vercel", "vercel.json");
|
||||||
|
expect(v.bunVersion).toBe("1.x");
|
||||||
|
expect(v.outputDirectory).toBe("dist");
|
||||||
|
expect(v.buildCommand).toBe("bunx --bun m3-bun build");
|
||||||
|
expect(v.rewrites[0]).toEqual({ source: "/api/(.*)", destination: "/api" });
|
||||||
|
expect(v.rewrites.at(-1)).toEqual({
|
||||||
|
source: "/((?!assets/|chunk-|api/).*)",
|
||||||
|
destination: "/index.html",
|
||||||
|
});
|
||||||
|
const spa = new RegExp(`^${v.rewrites.at(-1).source}$`);
|
||||||
|
expect(spa.test("/some/route")).toBe(true);
|
||||||
|
expect(spa.test("/chunk-abc.js")).toBe(false);
|
||||||
|
expect(spa.test("/api/x")).toBe(false);
|
||||||
|
expect(v.headers.flatMap((h: any) => h.headers.map((x: any) => x.key))).toContain(
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("api/index.ts adapts the app's routes to one fetch handler", () => {
|
||||||
|
const fn = read("vercel", "api/index.ts");
|
||||||
|
expect(fn).toContain('import { normalizeApi, routesToFetch } from "@aphrody/m3-bun/api";');
|
||||||
|
expect(fn).toContain('import api from "../src/api.ts";');
|
||||||
|
expect(fn).toContain("export default {");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fly", () => {
|
||||||
|
test("fly.toml parses and carries the app, region, service, check and VM", () => {
|
||||||
|
const fly = Bun.TOML.parse(read("fly", "fly.toml")) as Record<string, any>;
|
||||||
|
expect(fly.app).toBe("hello-m3");
|
||||||
|
expect(fly.primary_region).toBe("cdg");
|
||||||
|
expect(fly.build.dockerfile).toBe("Dockerfile");
|
||||||
|
expect(fly.env).toEqual({ PORT: "3456", HOST: "0.0.0.0" });
|
||||||
|
expect(fly.http_service).toMatchObject({
|
||||||
|
internal_port: 3456,
|
||||||
|
force_https: true,
|
||||||
|
auto_stop_machines: "stop",
|
||||||
|
auto_start_machines: true,
|
||||||
|
min_machines_running: 0,
|
||||||
|
});
|
||||||
|
expect(fly.http_service.checks[0]).toMatchObject({ method: "GET", path: "/healthz" });
|
||||||
|
expect(fly.vm[0]).toEqual({ size: "shared-cpu-1x", memory: "256mb" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripCommentLines = (text: string) => text.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
describe("cloudflare", () => {
|
||||||
|
test("wrangler.jsonc: worker, static assets with SPA fallback, worker-first API routes, custom domain", () => {
|
||||||
|
const w = JSON.parse(stripCommentLines(read("cloudflare", "wrangler.jsonc"))) as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>;
|
||||||
|
expect(w).toMatchObject({
|
||||||
|
name: "hello-m3",
|
||||||
|
main: "src/worker.ts",
|
||||||
|
assets: {
|
||||||
|
directory: "./dist",
|
||||||
|
binding: "ASSETS",
|
||||||
|
not_found_handling: "single-page-application",
|
||||||
|
run_worker_first: ["/api/*", "/healthz"],
|
||||||
|
},
|
||||||
|
routes: [{ pattern: "hello.example.com", custom_domain: true }],
|
||||||
|
});
|
||||||
|
expect(w.compatibility_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
test("worker and pages function reuse the app's routes; pages has _headers and _redirects", () => {
|
||||||
|
expect(read("cloudflare", "src/worker.ts")).toContain(
|
||||||
|
"routesToFetch(normalizeApi(api).routes)",
|
||||||
|
);
|
||||||
|
expect(read("cloudflare", "src/worker.ts")).toContain("env.ASSETS.fetch(request)");
|
||||||
|
expect(read("cloudflare", "pages/functions/[[path]].ts")).toContain("export const onRequest");
|
||||||
|
expect(read("cloudflare", "pages/_redirects")).toContain("/* /index.html 200");
|
||||||
|
expect(read("cloudflare", "pages/_headers")).toContain(
|
||||||
|
"Cache-Control: public, max-age=31536000, immutable",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("coolify", () => {
|
||||||
|
test("compose service with the SERVICE_FQDN routing variable and the executable's health check", () => {
|
||||||
|
const compose = Bun.YAML.parse(read("coolify", "docker-compose.yaml")) as {
|
||||||
|
services: Record<string, any>;
|
||||||
|
};
|
||||||
|
const service = compose.services["hello-m3"];
|
||||||
|
expect(service.environment).toContain("SERVICE_FQDN_HELLO_M3_3456=https://hello.example.com");
|
||||||
|
expect(service.environment).toContain("PORT=3456");
|
||||||
|
expect(service.healthcheck.test).toEqual(["CMD", "/app/server", "--healthcheck"]);
|
||||||
|
expect(service.build).toEqual({ context: ".", dockerfile: "Dockerfile" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vscode", () => {
|
||||||
|
test("tasks, launch configurations and extension recommendations are valid JSON with the m3-bun commands", () => {
|
||||||
|
const tasks = json("vscode", ".vscode/tasks.json");
|
||||||
|
const labels = tasks.tasks.map((t: any) => t.label);
|
||||||
|
for (const label of [
|
||||||
|
"m3-bun: dev",
|
||||||
|
"m3-bun: build",
|
||||||
|
"m3-bun: compile",
|
||||||
|
"m3-bun: serve",
|
||||||
|
"m3-bun: deploy (generate all targets)",
|
||||||
|
"m3-bun: package",
|
||||||
|
"m3-bun: smoke (compiled executable)",
|
||||||
|
"m3-bun: test",
|
||||||
|
])
|
||||||
|
expect(labels).toContain(label);
|
||||||
|
expect(tasks.tasks.find((t: any) => t.label === "m3-bun: build").group).toEqual({
|
||||||
|
kind: "build",
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
expect(tasks.tasks.find((t: any) => t.label === "m3-bun: dev").isBackground).toBe(true);
|
||||||
|
const launch = json("vscode", ".vscode/launch.json");
|
||||||
|
expect(launch.configurations.map((c: any) => c.type)).toEqual(["bun", "bun", "bun", "bun"]);
|
||||||
|
expect(json("vscode", ".vscode/extensions.json").recommendations).toEqual([
|
||||||
|
"oven.bun-vscode",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Bun extension of the fork declares what its debugger, tasks and settings accept.
|
||||||
|
const extension = join(
|
||||||
|
process.env.APHRODY_BUN_CHECKOUT ?? "C:/bun",
|
||||||
|
"packages/bun-vscode/package.json",
|
||||||
|
);
|
||||||
|
test.skipIf(!existsSync(extension))(
|
||||||
|
"launch.json and settings.json use only what the Bun extension declares",
|
||||||
|
async () => {
|
||||||
|
const { contributes } = (await Bun.file(extension).json()) as { contributes: any };
|
||||||
|
const debuggerSchema = contributes.debuggers.find(
|
||||||
|
(d: any) => d.type === "bun",
|
||||||
|
).configurationAttributes;
|
||||||
|
for (const config of json("vscode", ".vscode/launch.json").configurations) {
|
||||||
|
const known = Object.keys(debuggerSchema[config.request].properties);
|
||||||
|
const own = Object.keys(config).filter((key) => !["type", "request", "name"].includes(key));
|
||||||
|
expect({ name: config.name, unknown: own.filter((key) => !known.includes(key)) }).toEqual({
|
||||||
|
name: config.name,
|
||||||
|
unknown: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const properties = contributes.configuration.properties as Record<string, { type: string }>;
|
||||||
|
const settings = json("vscode", ".vscode/settings.json");
|
||||||
|
const bunSettings = Object.entries(settings).filter(([key]) => key.startsWith("bun."));
|
||||||
|
expect(bunSettings.length).toBeGreaterThan(5);
|
||||||
|
for (const [key, value] of bunSettings)
|
||||||
|
expect({ key, type: properties[key]?.type }).toEqual({ key, type: typeof value });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("windows", () => {
|
||||||
|
test("scripts target the .exe, use WinSW or a scheduled task, and never claim sc.exe can run a Bun executable", () => {
|
||||||
|
expect(read("windows", "build-windows.ps1")).toContain("build-windows.ts");
|
||||||
|
const build = read("windows", "build-windows.ts");
|
||||||
|
expect(build).toContain('compileApp({ app, target: "windows-x64"');
|
||||||
|
expect(build).toContain("windowsIcon(");
|
||||||
|
expect(read("windows", "icon.svg")).toStartWith("<svg ");
|
||||||
|
const app = read("windows", "install-app.ps1");
|
||||||
|
expect(app).toContain("WScript.Shell");
|
||||||
|
expect(app).toContain(
|
||||||
|
"Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\hello-m3",
|
||||||
|
);
|
||||||
|
expect(app).toContain("DisplayVersion = '1.2.3'");
|
||||||
|
expect(read("windows", "uninstall-app.ps1")).toContain("Remove-Item $key -Recurse");
|
||||||
|
expect(read("windows", "windows-check.ts")).toContain("inspectWindows(");
|
||||||
|
const install = read("windows", "install-service.ps1");
|
||||||
|
expect(install).toContain("WinSW-x64.exe");
|
||||||
|
expect(install).toContain("Register-ScheduledTask");
|
||||||
|
expect(install).toContain("$OpenFirewall");
|
||||||
|
expect(install).not.toContain("sc.exe create");
|
||||||
|
expect(read("windows", "uninstall-service.ps1")).toContain("Unregister-ScheduledTask");
|
||||||
|
const xml = read("windows", "hello-m3-service.xml");
|
||||||
|
expect(xml).toContain("<id>hello-m3</id>");
|
||||||
|
expect(xml).toContain('<env name="PORT" value="__PORT__" />');
|
||||||
|
const open = xml.match(/<[a-z]+[ >]/g)!.length;
|
||||||
|
expect(open).toBeGreaterThan(8);
|
||||||
|
});
|
||||||
|
test.skipIf(!Bun.which("pwsh"))(
|
||||||
|
"PowerShell parses every script without syntax errors",
|
||||||
|
() => {
|
||||||
|
const files = [
|
||||||
|
"build-windows.ps1",
|
||||||
|
"install-app.ps1",
|
||||||
|
"uninstall-app.ps1",
|
||||||
|
"install-service.ps1",
|
||||||
|
"uninstall-service.ps1",
|
||||||
|
].map((file) => join(out, "windows", file));
|
||||||
|
const script = `foreach ($f in @(${files.map((file) => `'${file}'`).join(", ")})) { $e = $null; $t = $null; [System.Management.Automation.Language.Parser]::ParseFile($f, [ref]$t, [ref]$e) | Out-Null; foreach ($x in $e) { "$(Split-Path $f -Leaf): $($x.Message)" } }`;
|
||||||
|
const proc = Bun.spawnSync(["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
expect({ out: proc.stdout.toString().trim(), code: proc.exitCode }).toEqual({
|
||||||
|
out: "",
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linux-image", () => {
|
||||||
|
const git = Bun.which("git");
|
||||||
|
const gitBash = git && join(dirname(dirname(git)), "bin/bash.exe");
|
||||||
|
const bash =
|
||||||
|
process.platform === "win32"
|
||||||
|
? gitBash && existsSync(gitBash)
|
||||||
|
? gitBash
|
||||||
|
: undefined
|
||||||
|
: Bun.which("bash");
|
||||||
|
test("README states the limit and generated scripts contain executable payloads", () => {
|
||||||
|
const readme = read("linux-image", "README.md");
|
||||||
|
expect(readme).toContain('a one-file or "no libc" image');
|
||||||
|
expect(readme).toContain("libc, libm, libdl, libpthread");
|
||||||
|
for (const file of ["build-image.sh", "run-qemu.sh"]) {
|
||||||
|
const path = join(out, "linux-image", file);
|
||||||
|
expect(Bun.file(path).size).toBeGreaterThan(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test.skipIf(!bash)("a qualified native Bash validates generated shell syntax", () => {
|
||||||
|
for (const file of ["build-image.sh", "run-qemu.sh"]) {
|
||||||
|
expect(
|
||||||
|
Bun.spawnSync([bash!, "-n", join(out, "linux-image", file).replaceAll("\\", "/")], {
|
||||||
|
windowsHide: true,
|
||||||
|
}).exitCode,
|
||||||
|
).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test.skipIf(process.platform === "win32")(
|
||||||
|
"a native POSIX host qualifies executable permissions",
|
||||||
|
() => {
|
||||||
|
for (const file of ["build-image.sh", "run-qemu.sh"]) {
|
||||||
|
const path = join(out, "linux-image", file);
|
||||||
|
expect(Bun.spawnSync(["test", "-x", path], { windowsHide: true }).exitCode).toBe(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test("build-image.sh delegates to the fork's initramfs.ts with the executable as the only Bun", () => {
|
||||||
|
const script = read("linux-image", "build-image.sh");
|
||||||
|
expect(script).toContain('BUN_BE_BUN=1 "$BIN" -e \'require("bun:linux")\'');
|
||||||
|
for (const f of ["initramfs.ts", "initramfs-init.ts", "initramfs-net.ts"])
|
||||||
|
expect(script).toContain(f);
|
||||||
|
expect(script).toContain(
|
||||||
|
'args=(--bun "$BIN" --argv0 hello-m3 --sysroot "$SYSROOT" --hostname hello-m3 --ip "$M3_IP")',
|
||||||
|
);
|
||||||
|
expect(script).toContain("args+=(--env PORT=3456 --env HOST=0.0.0.0)");
|
||||||
|
expect(script).toContain(
|
||||||
|
'bun "$SRC/initramfs.ts" "${args[@]}" --out "$OUT/initramfs.cpio.gz" -- /bin/bun',
|
||||||
|
);
|
||||||
|
expect(existsSync(join(out, "linux-image", "init.c"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
exportedScheme,
|
||||||
|
mcuM3Css,
|
||||||
|
m3CssFor,
|
||||||
|
nativeM3Css,
|
||||||
|
nativeThemeExport,
|
||||||
|
schemeVariant,
|
||||||
|
tsM3Css,
|
||||||
|
} from "../src/theme-css.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const native = await nativeM3Css();
|
||||||
|
const found = await nativeThemeExport(import.meta.dir, { seed: "#8c6c88" });
|
||||||
|
|
||||||
|
describe("mcuM3Css", () => {
|
||||||
|
test("tokens: static token CSS plus light and dark colour roles of the seed", () => {
|
||||||
|
const css = mcuM3Css({ sheet: "tokens", seed: "#6750a4" });
|
||||||
|
expect(css).toContain("--md-sys-shape-corner-medium: 12px");
|
||||||
|
expect(css).toContain("--md-sys-color-primary: #65558f");
|
||||||
|
expect(css).toContain("@media (prefers-color-scheme: dark)");
|
||||||
|
expect(css).toContain(':root[data-theme="dark"]');
|
||||||
|
});
|
||||||
|
test("scheme: one mode, so dark differs from light", () => {
|
||||||
|
const light = mcuM3Css({ sheet: "scheme", seed: "#6750a4" });
|
||||||
|
const dark = mcuM3Css({ sheet: "scheme", seed: "#6750a4", dark: true });
|
||||||
|
expect(light).toContain("--md-sys-color-primary: #65558f");
|
||||||
|
expect(dark).toContain("--md-sys-color-primary: #cfbdfe");
|
||||||
|
expect(light).not.toBe(dark);
|
||||||
|
});
|
||||||
|
test("seed, variant and contrast change the roles", () => {
|
||||||
|
const base = mcuM3Css({ sheet: "scheme", seed: "#6750a4" });
|
||||||
|
expect(mcuM3Css({ sheet: "scheme", seed: "#00897b" })).not.toBe(base);
|
||||||
|
expect(mcuM3Css({ sheet: "scheme", seed: "#6750a4", variant: "vibrant" })).not.toBe(base);
|
||||||
|
expect(mcuM3Css({ sheet: "scheme", seed: "#6750a4", contrast: 1 })).not.toBe(base);
|
||||||
|
});
|
||||||
|
test("the Rust-only sheets say what to use instead", () => {
|
||||||
|
expect(() => mcuM3Css({ sheet: "tailwind" })).toThrow(/m3-tailwind\/preset\.css/);
|
||||||
|
expect(() => mcuM3Css({ sheet: "spaceui" })).toThrow(/needs the Rust m3Css/);
|
||||||
|
});
|
||||||
|
test("schemeVariant accepts camelCase and kebab-case and rejects the rest", () => {
|
||||||
|
expect(schemeVariant(undefined)).toBe("tonalSpot");
|
||||||
|
expect(schemeVariant("tonal-spot")).toBe("tonalSpot");
|
||||||
|
expect(schemeVariant("tonalSpot")).toBe("tonalSpot");
|
||||||
|
expect(schemeVariant("monochrome")).toBe("monochrome");
|
||||||
|
expect(() => schemeVariant("rainbow")).toThrow(/unknown scheme variant/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("engines", () => {
|
||||||
|
test("tsM3Css is the Rust m3Css when it loads, else the MCU generator", () => {
|
||||||
|
const config = { sheet: "scheme", seed: "#00897b", dark: true } as const;
|
||||||
|
expect(tsM3Css(config)).toBe((native ?? mcuM3Css)(config));
|
||||||
|
});
|
||||||
|
test("m3CssFor: mcu on request, unknown engines are errors", async () => {
|
||||||
|
expect(await m3CssFor("mcu")).toBe(mcuM3Css);
|
||||||
|
await expect(m3CssFor("wasm" as never)).rejects.toThrow(/unknown css engine wasm \(native, mcu\)/);
|
||||||
|
});
|
||||||
|
test("m3CssFor native serves the committed default theme sheet", async () => {
|
||||||
|
const css = (await m3CssFor("native"))({ sheet: "tokens" });
|
||||||
|
expect(css).toContain("--md-sys-color-primary: #65558f");
|
||||||
|
});
|
||||||
|
test.skipIf(native !== undefined)("m3CssFor native names the missing library for a custom theme", async () => {
|
||||||
|
const generate = await m3CssFor("native");
|
||||||
|
expect(() => generate({ sheet: "scheme", seed: "#00897b" })).toThrow(/needs the @aphrody\/bun native library/);
|
||||||
|
});
|
||||||
|
test.skipIf(native === undefined)("m3CssFor native is the Rust m3Css for any theme", async () => {
|
||||||
|
const generate = await m3CssFor("native");
|
||||||
|
expect(generate({ sheet: "scheme", seed: "#00897b" })).toBe(native!({ sheet: "scheme", seed: "#00897b" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nativeThemeExport", () => {
|
||||||
|
test.skipIf(!found)(
|
||||||
|
"reuses the validated Rust ThemeExport above the app when it was exported for the theme",
|
||||||
|
async () => {
|
||||||
|
expect(found!.path.replaceAll("\\", "/")).toEndWith("var/mirrors/design-sync/tokens.json");
|
||||||
|
expect(found!.theme).toMatchObject({
|
||||||
|
schema_version: 1,
|
||||||
|
seed: "#8c6c88",
|
||||||
|
variant: "tonal-spot",
|
||||||
|
contrast_level: 0,
|
||||||
|
});
|
||||||
|
expect(Object.keys(found!.theme.light)).toHaveLength(49);
|
||||||
|
expect(await nativeThemeExport(import.meta.dir, { seed: "#8c6c88", contrast: 0.5 })).toBeUndefined();
|
||||||
|
expect(await nativeThemeExport(import.meta.dir, { seed: "#6750a4" })).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test.skipIf(!found)("m3CssFor native serves the ThemeExport for its own theme, without the native library", async () => {
|
||||||
|
const generate = await m3CssFor("native", import.meta.dir);
|
||||||
|
const scheme = generate({ sheet: "scheme", seed: "#8c6c88", dark: true });
|
||||||
|
expect(scheme).toBe(exportedScheme(found!.theme, true));
|
||||||
|
expect(scheme).toContain(`--md-sys-color-primary-container: ${found!.theme.dark.primaryContainer};`);
|
||||||
|
expect(generate({ sheet: "tokens", seed: "#8c6c88" })).toContain(`--md-sys-color-primary: ${found!.theme.light.primary}`);
|
||||||
|
expect(generate({ sheet: "tokens" })).toContain("--md-sys-color-primary: #65558f");
|
||||||
|
});
|
||||||
|
test("APHRODY_DESIGN_CORPUS without an export gives nothing", async () => {
|
||||||
|
const saved = Bun.env.APHRODY_DESIGN_CORPUS;
|
||||||
|
Bun.env.APHRODY_DESIGN_CORPUS = tmp("m3-bun-corpus-");
|
||||||
|
try {
|
||||||
|
expect(await nativeThemeExport(import.meta.dir, { seed: "#8c6c88" })).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete Bun.env.APHRODY_DESIGN_CORPUS;
|
||||||
|
else Bun.env.APHRODY_DESIGN_CORPUS = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createApp, createTemplates } from "../src/tools-create.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
describe("createApp", () => {
|
||||||
|
test("offers the templates of @aphrody/scaffold", async () => {
|
||||||
|
expect((await createTemplates()).map(template => template.name)).toEqual(["web", "app", "spa", "example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dry run lists the rendered files and names the package after the directory", async () => {
|
||||||
|
const dir = join(tmp("m3-bun-create-"), "My App");
|
||||||
|
const report = await createApp({ template: "web", dir, dryRun: true });
|
||||||
|
expect(report).toMatchObject({ template: "web", name: "my-app", written: false });
|
||||||
|
expect(report.files).toEqual([
|
||||||
|
".gitignore",
|
||||||
|
"bunfig.toml",
|
||||||
|
"index.html",
|
||||||
|
"m3.config.ts",
|
||||||
|
"package.json",
|
||||||
|
"src/main.ts",
|
||||||
|
]);
|
||||||
|
expect((await createApp({ template: "app", dir, name: "demo", dryRun: true })).files).toEqual(
|
||||||
|
expect.arrayContaining(["m3.config.ts", "package.json", "src/App.tsx", "src/index.html"]),
|
||||||
|
);
|
||||||
|
expect(existsSync(dir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writing keeps the scaffolder's guard: only a declared member of the workspace", async () => {
|
||||||
|
const dir = join(tmp("m3-bun-create-"), "outside");
|
||||||
|
await expect(createApp({ template: "spa", dir })).rejects.toThrow(/private owner workspace required/);
|
||||||
|
expect(existsSync(dir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown template is an error that lists the templates", async () => {
|
||||||
|
await expect(createApp({ template: "nope" as never, dir: tmp(), dryRun: true })).rejects.toThrow(
|
||||||
|
/unknown template nope \(web, app, spa, example\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { nativeM3Css } from "../src/theme-css.ts";
|
||||||
|
import { designApp } from "../src/tools-design.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
const BRIEF = "A calm dashboard for a coffee shop with orders and a revenue chart";
|
||||||
|
const native = await nativeM3Css();
|
||||||
|
|
||||||
|
describe("designApp", () => {
|
||||||
|
test("compiles a brief to a layout, a theme sheet and tokens in the requested seed", async () => {
|
||||||
|
const report = await designApp({ prompt: BRIEF, color: "#8c6c88" });
|
||||||
|
expect(report.brief).toMatchObject({ outputKind: "dashboard", seedColor: "#8c6c88", rawBrief: BRIEF });
|
||||||
|
expect(report.seed.toLowerCase()).toBe("#8c6c88");
|
||||||
|
expect(report.files.map(file => file.path)).toEqual(["src/theme.css", "src/tokens.json", "src/index.tsx"]);
|
||||||
|
expect(report.files.every(file => file.bytes > 0)).toBe(true);
|
||||||
|
expect(report.written).toEqual([]);
|
||||||
|
expect(report.result.files.find(file => file.path === "src/theme.css")!.content).toContain(
|
||||||
|
"--md-sys-color-primary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("out writes the generated files", async () => {
|
||||||
|
const out = tmp("m3-bun-design-");
|
||||||
|
const report = await designApp({ prompt: BRIEF, out });
|
||||||
|
expect(report.written).toEqual(report.files.map(file => join(out, file.path)));
|
||||||
|
const tokens = JSON.parse(readFileSync(join(out, "src", "tokens.json"), "utf8")) as object;
|
||||||
|
expect(typeof tokens).toBe("object");
|
||||||
|
expect(readFileSync(join(out, "src", "index.tsx"), "utf8")).toContain("@aphrody/m3-react");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty brief is an error", async () => {
|
||||||
|
await expect(designApp({ prompt: " " })).rejects.toThrow(/the brief is empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(native !== undefined)("validate without the native library says what is missing", async () => {
|
||||||
|
await expect(designApp({ prompt: BRIEF, validate: true })).rejects.toThrow(/native library not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { buildLibrary } from "../src/tools-lib.ts";
|
||||||
|
import { loadTool } from "../src/tools-load.ts";
|
||||||
|
import { cleanup, tmp } from "./helpers.ts";
|
||||||
|
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
function library(): string {
|
||||||
|
const dir = tmp("m3-bun-lib-");
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "lib", dependencies: { react: "19.0.0" } }));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "a.ts"),
|
||||||
|
'import { useState } from "react";\nimport { twice } from "./util.ts";\nexport const a = () => twice(useState(1)[0]);\n',
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, "src", "util.ts"), "export const twice = (n: number) => n * 2;\n");
|
||||||
|
writeFileSync(join(dir, "src", "b.ts"), "export const b = 2;\n");
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "build.config.ts"),
|
||||||
|
'export default { entry: ["src/a.ts", "src/b.ts"], useClient: ["a.js"] };\n',
|
||||||
|
);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildLibrary", () => {
|
||||||
|
test("applies build.config.ts through m3BunBuild: dependencies external, 'use client' where asked", async () => {
|
||||||
|
const dir = library();
|
||||||
|
const report = await buildLibrary({ dir });
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
outDir: join(dir, "dist"),
|
||||||
|
config: join(dir, "build.config.ts"),
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
expect(report.files.map(file => file.path)).toEqual(["a.js", "a.js.map", "b.js", "b.js.map"]);
|
||||||
|
for (const file of report.files) {
|
||||||
|
const bytes = new Uint8Array(readFileSync(join(dir, "dist", file.path)));
|
||||||
|
expect(file.bytes).toBe(bytes.length);
|
||||||
|
expect(file.zstdBytes).toBe(Bun.zstdCompressSync(bytes).length);
|
||||||
|
}
|
||||||
|
const a = readFileSync(join(dir, "dist", "a.js"), "utf8");
|
||||||
|
expect(a).toStartWith("'use client';\n");
|
||||||
|
expect(a).toContain('import { useState } from "react";');
|
||||||
|
expect(a).toContain("var twice = (n) => n * 2;");
|
||||||
|
expect(readFileSync(join(dir, "dist", "b.js"), "utf8")).not.toContain("use client");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("options override the config; a failed build reports Bun's logs", async () => {
|
||||||
|
const report = await buildLibrary({ dir: library(), entry: "src/missing.ts" });
|
||||||
|
expect(report.success).toBe(false);
|
||||||
|
expect(report.files).toEqual([]);
|
||||||
|
expect(report.logs.join("\n")).toMatch(/ModuleNotFound.*missing\.ts/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadTool", () => {
|
||||||
|
test("a missing package becomes an error that says what to install", async () => {
|
||||||
|
const error = await loadTool("@aphrody/not-installed", () => import("@aphrody/not-installed" as string)).catch(
|
||||||
|
(e: Error) => e,
|
||||||
|
);
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBe(
|
||||||
|
"m3-bun: @aphrody/not-installed is not installed; add it next to @aphrody/m3-bun (bun add @aphrody/not-installed)",
|
||||||
|
);
|
||||||
|
expect((error as Error).cause).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other load errors are rethrown as they are", async () => {
|
||||||
|
const thrown = new SyntaxError("broken module");
|
||||||
|
await expect(
|
||||||
|
loadTool("@aphrody/m3", () => {
|
||||||
|
throw thrown;
|
||||||
|
}),
|
||||||
|
).rejects.toBe(thrown);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user