clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / package / astro / mod.ts
mod.ts 12.9K
1/**2 * Astro and Vite integration used by generated lofi applications.3 *4 * {@link prepareLofiAstroConfig} materializes version-matched runtime aliases5 * and Jazz configuration into the ignored `.lofi/` directory.6 *7 * The generated configuration reads these environment variables at Astro8 * config-load time:9 *10 * - `LOFI_BASE_PATH` — deployment base path for the built site (default `/`).11 * - `LOFI_CSP` — set to `off` to disable the Content-Security-Policy meta tags12 * Astro emits per page (on by default).13 * - `LOFI_CSP_SCRIPT_SRC` — space-separated sources appended to the14 * `script-src` directive.15 * - `LOFI_CSP_STYLE_SRC` — space-separated sources appended to the16 * `style-src` directive.17 * - `LOFI_CSP_CONNECT_SRC` — sources for a `connect-src` directive; absent by18 * default because the sync location is user data enrolled at runtime.19 * - `LOFI_CSP_DIRECTIVES` — semicolon-separated additional CSP directives.20 * - `JAZZ_APP_ID` — cloud-mode Jazz application id; requires21 * `JAZZ_SERVER_URL`.22 * - `JAZZ_SERVER_URL` — cloud-mode Jazz sync server URL; requires23 * `JAZZ_APP_ID`.24 * - `LOFI_SKIP_JAZZ_MANAGED` — set to `1` to skip starting the managed local25 * Jazz server during development.26 *27 * @module28 */2930import { join, resolve } from "node:path";31import { accessFiles, preactFiles, recipeFiles, runtimeFiles, schemaFiles } from "./manifest.ts";3233/** Options for materializing the package-owned Astro configuration. */34export type LofiAstroOptions = {35 /** Project root. Defaults to the directory from which Astro was invoked. */36 root?: string;37 /**38 * The directory the generated integration reads the Jazz schema and39 * permissions from. `src` is the contract: the type admits nothing else,40 * and the runtime check rejects any other value.41 * @default "src"42 */43 schemaDir?: "src";44};4546async function readPackageFile(path: string): Promise<string> {47 const url = new URL(path, import.meta.url);48 if (url.protocol === "file:") return await Deno.readTextFile(url);49 const response = await fetch(url);50 if (!response.ok) {51 throw new Error(`failed to read package module ${path}: HTTP ${response.status}`);52 }53 return await response.text();54}5556function renderConfig(projectRoot: string): string {57 const runtimeEntry = join(projectRoot, ".lofi", "package", "runtime", "mod.ts");58 const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");59 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");60 const schemaEntry = join(projectRoot, ".lofi", "package", "schema", "mod.ts");61 const schemaStoreEntry = join(projectRoot, ".lofi", "package", "schema", "store.ts");62 const fileHandlerRecipe = join(projectRoot, ".lofi", "package", "recipes", "file-handler.ts");63 const launchHandlerRecipe = join(64 projectRoot,65 ".lofi",66 "package",67 "recipes",68 "launch-handler.ts",69 );70 const protocolHandlerRecipe = join(71 projectRoot,72 ".lofi",73 "package",74 "recipes",75 "protocol-handler.ts",76 );77 const relatedAppDiscoveryRecipe = join(78 projectRoot,79 ".lofi",80 "package",81 "recipes",82 "related-app-discovery.ts",83 );84 const scopeExtensionRecipe = join(85 projectRoot,86 ".lofi",87 "package",88 "recipes",89 "scope-extension.ts",90 );91 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");92 const windowControlsOverlayRecipe = join(93 projectRoot,94 ".lofi",95 "package",96 "recipes",97 "window-controls-overlay.ts",98 );99 return `import preact from "@astrojs/preact";100import { defineConfig } from "astro/config";101import { jazzPlugin } from "jazz-tools/dev/vite";102import { resolve } from "node:path";103// The vendored runtime's crypto dependencies are imported only by files Vite104// serves, so nothing on the Deno side would otherwise materialize them into105// node_modules where Vite resolves bare specifiers. Loading them here pins106// them into the project's node_modules at config time.107import "@noble/ciphers/chacha";108import "@noble/curves/ed25519";109import "@noble/hashes/hkdf";110import "@noble/hashes/sha2";111112const projectRoot = Deno.cwd();113const schemaDir = resolve(projectRoot, "src");114const cloudAppId = process.env.JAZZ_APP_ID?.trim();115const cloudServerUrl = process.env.JAZZ_SERVER_URL?.trim();116const managedJazzServer = process.env.LOFI_SKIP_JAZZ_MANAGED === "1"117 ? false118 : { allowLocalFirstAuth: true };119const deploymentBase = process.env.LOFI_BASE_PATH || "/";120// Content-Security-Policy: on by default; Astro emits a per-page meta tag121// with hashes for its inline island scripts. 'wasm-unsafe-eval' admits the122// Jazz engine's WebAssembly.instantiate; connect-src is absent by default123// because the sync location is user data enrolled at runtime, not build124// configuration. LOFI_CSP=off disables (reported by the build, not blocked).125const cspOff = (process.env.LOFI_CSP || "").trim() === "off";126const cspList = (name) => (process.env[name] || "").split(" ").filter(Boolean);127const cspExtraDirectives = (process.env.LOFI_CSP_DIRECTIVES || "")128 .split(";").map((entry) => entry.trim()).filter(Boolean);129const cspConnect = (process.env.LOFI_CSP_CONNECT_SRC || "").trim();130const cspSecurity = cspOff ? {} : {131 csp: {132 scriptDirective: {133 resources: ["'self'", "'wasm-unsafe-eval'", ...cspList("LOFI_CSP_SCRIPT_SRC")],134 },135 styleDirective: { resources: ["'self'", ...cspList("LOFI_CSP_STYLE_SRC")] },136 directives: [137 "object-src 'none'",138 "base-uri 'self'",139 "worker-src 'self'",140 ...(cspConnect ? ["connect-src " + cspConnect] : []),141 ...cspExtraDirectives,142 ],143 },144};145146if (Boolean(cloudAppId) !== Boolean(cloudServerUrl)) {147 throw new Error(148 "Cloud mode requires both JAZZ_APP_ID and JAZZ_SERVER_URL; set the missing name or remove the partial pair.",149 );150}151152if (cloudAppId && cloudServerUrl) {153 process.env.VITE_JAZZ_APP_ID = cloudAppId;154 process.env.VITE_JAZZ_SERVER_URL = cloudServerUrl;155}156157export default defineConfig({158 base: deploymentBase,159 output: "static",160 security: cspSecurity,161 trailingSlash: "always",162 integrations: [preact({ compat: true, devtools: true })],163 vite: {164 plugins: [jazzPlugin({ schemaDir, inspector: false, server: managedJazzServer })],165 server: { fs: { allow: [projectRoot] } },166 // The worker sub-build falls back to Vite's default asset template167 // ("[name]-[hash][extname]") while Astro names client assets168 // "[name].[hash][extname]", so the Jazz WASM binary — imported by both the169 // runtime and the jazz worker — lands in dist twice under two names.170 // Matching Astro's template collapses the copies into one file.171 worker: {172 rollupOptions: {173 output: { assetFileNames: "_astro/[name].[hash][extname]" },174 },175 },176 define: {177 __LOFI_JAZZ_APP_ID__: JSON.stringify(cloudAppId ?? ""),178 __LOFI_JAZZ_SERVER_URL__: JSON.stringify(cloudServerUrl ?? ""),179 },180 resolve: {181 alias: [182 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/file-handler$/, replacement: ${183 JSON.stringify(fileHandlerRecipe)184 } },185 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/launch-handler$/, replacement: ${186 JSON.stringify(launchHandlerRecipe)187 } },188 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/protocol-handler$/, replacement: ${189 JSON.stringify(protocolHandlerRecipe)190 } },191 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/related-app-discovery$/, replacement: ${192 JSON.stringify(relatedAppDiscoveryRecipe)193 } },194 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/scope-extension$/, replacement: ${195 JSON.stringify(scopeExtensionRecipe)196 } },197 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${198 JSON.stringify(webShareRecipe)199 } },200 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/window-controls-overlay$/, replacement: ${201 JSON.stringify(windowControlsOverlayRecipe)202 } },203 { find: /^jsr:@nzip\\/lofi@[^/]+\\/preact$/, replacement: ${JSON.stringify(preactEntry)} },204 { find: /^jsr:@nzip\\/lofi@[^/]+\\/access$/, replacement: ${JSON.stringify(accessEntry)} },205 { find: /^jsr:@nzip\\/lofi@[^/]+\\/schema\\/store$/, replacement: ${206 JSON.stringify(schemaStoreEntry)207 } },208 { find: /^jsr:@nzip\\/lofi@[^/]+\\/schema$/, replacement: ${JSON.stringify(schemaEntry)} },209 { find: /^jsr:@nzip\\/lofi@[^/]+$/, replacement: ${JSON.stringify(runtimeEntry)} },210 { find: /^npm:preact@[^/]+\\/hooks$/, replacement: "preact/hooks" },211 { find: /^npm:preact@[^/]+\\/jsx-dev-runtime$/, replacement: "preact/jsx-dev-runtime" },212 { find: /^npm:preact@[^/]+\\/jsx-runtime$/, replacement: "preact/jsx-runtime" },213 { find: /^npm:preact@[^/]+$/, replacement: "preact" },214 { find: /^npm:@noble\\/ciphers@[^/]+\\/chacha$/, replacement: "@noble/ciphers/chacha" },215 { find: /^npm:@noble\\/curves@[^/]+\\/ed25519$/, replacement: "@noble/curves/ed25519" },216 { find: /^npm:@noble\\/hashes@[^/]+\\/hkdf$/, replacement: "@noble/hashes/hkdf" },217 { find: /^npm:@noble\\/hashes@[^/]+\\/sha2$/, replacement: "@noble/hashes/sha2" },218 { find: /^npm:jazz-tools@[^/]+\\/passkey-backup$/, replacement: "jazz-tools/passkey-backup" },219 { find: /^npm:jazz-tools@[^/]+\\/passphrase$/, replacement: "jazz-tools/passphrase" },220 { find: /^npm:jazz-tools@[^/]+$/, replacement: "jazz-tools" },221 { find: "@nzip/lofi/recipes/launch-handler", replacement: ${222 JSON.stringify(launchHandlerRecipe)223 } },224 { find: "@nzip/lofi/recipes/protocol-handler", replacement: ${225 JSON.stringify(protocolHandlerRecipe)226 } },227 { find: "@nzip/lofi/recipes/related-app-discovery", replacement: ${228 JSON.stringify(relatedAppDiscoveryRecipe)229 } },230 { find: "@nzip/lofi/recipes/scope-extension", replacement: ${231 JSON.stringify(scopeExtensionRecipe)232 } },233 { find: "@nzip/lofi/recipes/file-handler", replacement: ${234 JSON.stringify(fileHandlerRecipe)235 } },236 { find: "@nzip/lofi/recipes/web-share", replacement: ${JSON.stringify(webShareRecipe)} },237 { find: "@nzip/lofi/recipes/window-controls-overlay", replacement: ${238 JSON.stringify(windowControlsOverlayRecipe)239 } },240 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },241 { find: "@nzip/lofi/access", replacement: ${JSON.stringify(accessEntry)} },242 { find: "@nzip/lofi/schema", replacement: ${JSON.stringify(schemaEntry)} },243 { find: "@nzip/lofi", replacement: ${JSON.stringify(runtimeEntry)} },244 ],245 noExternal: ["@astrojs/preact"],246 },247 },248});249`;250}251252/**253 * Materializes the package-owned Astro/Jazz integration into an ignored tooling254 * directory so Astro's Node-compatible config loader can consume it.255 *256 * Vendors the runtime, access, preact, recipe, and schema modules into257 * `.lofi/package/` and writes `.lofi/astro.config.ts`, rewriting the config258 * only when its content changes.259 *260 * @param options The project root and schema directory; both optional.261 * @returns The absolute path of the generated `.lofi/astro.config.ts`.262 */263export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Promise<string> {264 const projectRoot = resolve(options.root ?? Deno.cwd());265 // Type-level contract; kept at runtime against unchecked JavaScript callers.266 if (options.schemaDir !== undefined && options.schemaDir !== "src") {267 throw new Error("custom schemaDir is not supported by the generated lofi Astro integration");268 }269 const toolingDir = join(projectRoot, ".lofi");270 const path = join(toolingDir, "astro.config.ts");271 const packageDir = join(toolingDir, "package");272 await Deno.mkdir(join(packageDir, "runtime"), { recursive: true });273 await Deno.mkdir(join(packageDir, "access"), { recursive: true });274 await Deno.mkdir(join(packageDir, "preact"), { recursive: true });275 await Deno.mkdir(join(packageDir, "recipes"), { recursive: true });276 await Deno.mkdir(join(packageDir, "schema"), { recursive: true });277 for (const file of runtimeFiles) {278 await Deno.writeTextFile(279 join(packageDir, "runtime", file),280 await readPackageFile(`../runtime/${file}`),281 );282 }283 for (const file of preactFiles) {284 await Deno.writeTextFile(285 join(packageDir, "preact", file),286 await readPackageFile(`../preact/${file}`),287 );288 }289 for (const file of accessFiles) {290 await Deno.writeTextFile(291 join(packageDir, "access", file),292 await readPackageFile(`../access/${file}`),293 );294 }295 for (const file of recipeFiles) {296 await Deno.writeTextFile(297 join(packageDir, "recipes", file),298 await readPackageFile(`../recipes/${file}`),299 );300 }301 for (const file of schemaFiles) {302 await Deno.writeTextFile(303 join(packageDir, "schema", file),304 await readPackageFile(`../schema/${file}`),305 );306 }307 const configSource = renderConfig(projectRoot);308 let current = "";309 try {310 current = await Deno.readTextFile(path);311 } catch (error) {312 if (!(error instanceof Deno.errors.NotFound)) throw error;313 }314 if (current !== configSource) await Deno.writeTextFile(path, configSource);315 return path;316}