bosia 0.9.8 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -4
  3. package/src/ambient.d.ts +7 -0
  4. package/src/cli/build.ts +6 -1
  5. package/src/cli/index.ts +3 -3
  6. package/src/cli/start.ts +9 -1
  7. package/src/core/appHtml.ts +3 -11
  8. package/src/core/artifacts.ts +21 -0
  9. package/src/core/backend.ts +238 -0
  10. package/src/core/build.ts +97 -6
  11. package/src/core/cache.ts +10 -6
  12. package/src/core/client/App.svelte +5 -2
  13. package/src/core/client/prefetch.ts +3 -1
  14. package/src/core/config.ts +20 -9
  15. package/src/core/dev.ts +2 -3
  16. package/src/core/env.ts +0 -20
  17. package/src/core/hooks.ts +28 -0
  18. package/src/core/html.ts +29 -24
  19. package/src/core/paths.ts +4 -2
  20. package/src/core/platform.ts +24 -0
  21. package/src/core/plugin.ts +26 -1
  22. package/src/core/plugins/inspector/index.ts +6 -10
  23. package/src/core/plugins/server-timing.ts +1 -1
  24. package/src/core/prerender.ts +6 -6
  25. package/src/core/renderer.ts +15 -8
  26. package/src/core/routeFile.ts +4 -0
  27. package/src/core/scanner.ts +30 -0
  28. package/src/core/server.bun.ts +129 -0
  29. package/src/core/server.ts +108 -204
  30. package/src/core/server.workers.ts +46 -0
  31. package/src/core/types/plugin.ts +10 -6
  32. package/src/core/types.ts +2 -0
  33. package/src/core/workersCodegen.ts +110 -0
  34. package/src/core/workersGuard.ts +107 -0
  35. package/src/lib/index.ts +3 -0
  36. package/src/lib/server.ts +1 -0
  37. package/templates/default/README.md +1 -1
  38. package/templates/default/_gitignore +2 -0
  39. package/templates/default/src/routes/(public)/+page.svelte +1 -1
  40. package/templates/demo/_gitignore +2 -0
  41. package/templates/demo/src/routes/(public)/+page.svelte +2 -2
  42. package/templates/demo/src/routes/(public)/about/+page.svelte +2 -2
  43. package/templates/shop/_gitignore +2 -0
  44. package/templates/store/_gitignore +2 -0
@@ -17,7 +17,9 @@ export function buildMaskBits(path: string): string | null {
17
17
  const url = new URL(path, window.location.origin);
18
18
  const pathname = url.pathname;
19
19
  const match = findMatch(clientRoutes, pathname);
20
- if (!match) return null;
20
+ // Prerendered data is a fixed file: nothing to skip, and on Workers only a
21
+ // plain GET reaches it (the asset server answers a POST with 405).
22
+ if (!match || match.route.prerender) return null;
21
23
  const ctx = liveContext(pathname, match.params, url);
22
24
  const layoutIds = (match.route as any).layoutIds as (string | null)[];
23
25
  const pageId = (match.route as any).pageId as string | null;
@@ -9,7 +9,7 @@ let cachedFromPath: string | null = null;
9
9
 
10
10
  const CONFIG_NAMES = ["bosia.config.ts", "bosia.config.js", "bosia.config.mjs"];
11
11
 
12
- function findConfigPath(cwd: string): string | null {
12
+ export function findConfigPath(cwd: string): string | null {
13
13
  for (const name of CONFIG_NAMES) {
14
14
  const p = join(cwd, name);
15
15
  if (existsSync(p)) return p;
@@ -39,9 +39,7 @@ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<Bosi
39
39
  `${prebuiltPath} must export a default object (use \`export default defineConfig({...})\`).`,
40
40
  );
41
41
  }
42
- const rawPlugins = Array.isArray(config.plugins) ? config.plugins : [];
43
- const plugins = rawPlugins.filter((p): p is BosiaPlugin => Boolean(p));
44
- cached = { plugins };
42
+ cached = normalizeConfig(config);
45
43
  cachedFromPath = cwd;
46
44
  return cached;
47
45
  }
@@ -57,7 +55,7 @@ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<Bosi
57
55
  entrypoints: [configPath],
58
56
  target: "bun",
59
57
  format: "esm",
60
- external: ["bosia", "elysia", "bun", "svelte", "svelte/server"],
58
+ external: ["bosia", "bun", "svelte", "svelte/server"],
61
59
  });
62
60
 
63
61
  if (!result.success || !result.outputs[0]) {
@@ -89,13 +87,26 @@ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<Bosi
89
87
  );
90
88
  }
91
89
 
90
+ cached = normalizeConfig(config);
91
+ cachedFromPath = cwd;
92
+ return cached;
93
+ }
94
+
95
+ // Keep every field — only `plugins` needs cleaning. Rebuilding the object as
96
+ // `{ plugins }` silently dropped `strictImports` and anything added later.
97
+ function normalizeConfig(config: BosiaConfig): BosiaConfig {
92
98
  const rawPlugins = Array.isArray(config.plugins) ? config.plugins : [];
93
- const plugins = rawPlugins.filter((p): p is BosiaPlugin => Boolean(p));
94
- const normalized: BosiaConfig = { plugins };
99
+ return { ...config, plugins: rawPlugins.filter((p): p is BosiaPlugin => Boolean(p)) };
100
+ }
95
101
 
96
- cached = normalized;
102
+ /**
103
+ * Hand over an already-imported config. For runtimes that can't load
104
+ * `bosia.config.ts` off disk (Workers bundles it statically); call before
105
+ * anything reads the config.
106
+ */
107
+ export function setBosiaConfig(config: BosiaConfig, cwd: string = process.cwd()): void {
108
+ cached = normalizeConfig(config);
97
109
  cachedFromPath = cwd;
98
- return normalized;
99
110
  }
100
111
 
101
112
  /** Test-only — drops the in-memory cache so tests can reload fresh config files. */
package/src/core/dev.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
2
  import { readdirSync, statSync, watch, type Dirent } from "fs";
3
3
  import { join } from "path";
4
- import { loadEnv, resetDeclaredKeys } from "./env.ts";
4
+ import { loadEnv } from "./env.ts";
5
5
  import { BOSIA_NODE_PATH } from "./paths.ts";
6
6
  import { pidsOnPort } from "./port.ts";
7
7
  import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
@@ -31,7 +31,6 @@ function reloadEnv() {
31
31
  for (const [k, v] of Object.entries(SHELL_ENV_SNAPSHOT)) {
32
32
  if (v !== undefined) process.env[k] = v;
33
33
  }
34
- resetDeclaredKeys();
35
34
  loadEnv("development");
36
35
  }
37
36
 
@@ -211,7 +210,7 @@ async function startAppServer() {
211
210
  NODE_ENV: "development",
212
211
  // Force app server to APP_PORT — prevents PORT from .env conflicting with the dev proxy
213
212
  PORT: String(APP_PORT),
214
- // Allow externalized deps (elysia, etc.) to resolve from bosia's node_modules
213
+ // Allow externalized deps to resolve from bosia's node_modules
215
214
  NODE_PATH: BOSIA_NODE_PATH,
216
215
  // Point the server child at dev's output dir so its OUT_DIR reads match what build wrote.
217
216
  BOSIA_OUT_DIR: DEV_OUT_DIR,
package/src/core/env.ts CHANGED
@@ -162,11 +162,6 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
162
162
  for (const key of Object.keys(parsed)) declaredNames.add(key);
163
163
  }
164
164
 
165
- // Track declared keys so html.ts only exposes .env-declared PUBLIC_* vars
166
- for (const key of declaredNames) {
167
- _declaredKeys.add(key);
168
- }
169
-
170
165
  // Apply to process.env — system env wins (don't overwrite existing).
171
166
  // Name-only keys stay absent so `process.env.X ?? default` still falls back.
172
167
  for (const [key, value] of Object.entries(merged)) {
@@ -186,21 +181,6 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
186
181
  return result;
187
182
  }
188
183
 
189
- // ─── Declared Key Tracking ───────────────────────────
190
- // Track which keys were declared in .env files so html.ts only exposes those to the client.
191
-
192
- const _declaredKeys = new Set<string>();
193
-
194
- /** Returns the set of env var keys that were declared in .env files. */
195
- export function getDeclaredEnvKeys(): ReadonlySet<string> {
196
- return _declaredKeys;
197
- }
198
-
199
- /** Clear the declared-keys set. Call before re-running `loadEnv` on hot-reload so removed PUBLIC_* keys stop leaking to the client. */
200
- export function resetDeclaredKeys(): void {
201
- _declaredKeys.clear();
202
- }
203
-
204
184
  // ─── Classifier ──────────────────────────────────────────
205
185
 
206
186
  export interface ClassifiedEnv {
package/src/core/hooks.ts CHANGED
@@ -31,6 +31,19 @@ export interface Cookies {
31
31
 
32
32
  // ─── Event Types ──────────────────────────────────────────
33
33
 
34
+ /**
35
+ * Runtime bindings (Cloudflare Workers: D1, KV, R2, vars). Augment to type them:
36
+ * `declare module "bosia" { interface PlatformEnv { DB: D1Database } }`
37
+ */
38
+ export interface PlatformEnv {
39
+ [key: string]: any;
40
+ }
41
+
42
+ /** What the host runtime hands the app. */
43
+ export interface Platform {
44
+ env: PlatformEnv;
45
+ }
46
+
34
47
  export type RequestEvent = {
35
48
  request: Request;
36
49
  url: URL;
@@ -56,6 +69,11 @@ export type RequestEvent = {
56
69
  * authorization: a check that runs on one kind and not the other is a hole.
57
70
  */
58
71
  isDataRequest: boolean;
72
+ /**
73
+ * Runtime platform context. On Cloudflare Workers, `platform.env` holds the
74
+ * bindings (D1, KV, R2, …). `undefined` on Bun.
75
+ */
76
+ platform?: Platform;
59
77
  };
60
78
 
61
79
  export type LoadEvent = {
@@ -73,6 +91,11 @@ export type LoadEvent = {
73
91
  * namespaced (e.g. `"app:user"`).
74
92
  */
75
93
  depends: (...keys: string[]) => void;
94
+ /**
95
+ * Runtime platform context. On Cloudflare Workers, `platform.env` holds the
96
+ * bindings (D1, KV, R2, …). `undefined` on Bun.
97
+ */
98
+ platform?: Platform;
76
99
  /**
77
100
  * Set response headers for this request. Headers accumulate across
78
101
  * layout and page loaders and land on both the SSR HTML response and
@@ -150,6 +173,11 @@ export type MetadataEvent = {
150
173
  locals: Record<string, any>;
151
174
  cookies: Cookies;
152
175
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
176
+ /**
177
+ * Runtime platform context. On Cloudflare Workers, `platform.env` holds the
178
+ * bindings (D1, KV, R2, …). `undefined` on Bun.
179
+ */
180
+ platform?: Platform;
153
181
  };
154
182
 
155
183
  export type Metadata = {
package/src/core/html.ts CHANGED
@@ -1,13 +1,17 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { getDeclaredEnvKeys } from "./env.ts";
1
+ import { gzipSync } from "node:zlib";
2
+
3
+ import { readArtifact } from "./artifacts.ts";
3
4
  import { nonceAttr } from "./csp.ts";
4
- import { OUT_DIR } from "./paths.ts";
5
5
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
6
  import { currentBase } from "./appBase.ts";
7
7
  import type { AppHtmlSegments } from "./appHtml.ts";
8
8
  import { interpolateSegment } from "./appHtml.ts";
9
9
  import type { Metadata } from "./hooks.ts";
10
10
 
11
+ // Workers compresses any body sent with a Content-Encoding header — again, if it
12
+ // already is — unless told the bytes are final. Bun ignores the key.
13
+ export const PRECOMPRESSED = { encodeBody: "manual" } as ResponseInit;
14
+
11
15
  // ─── Dist Manifest ───────────────────────────────────────
12
16
  // Maps hashed filenames → script/link tags.
13
17
  // Cached at startup; server restarts on rebuild in dev anyway.
@@ -18,12 +22,9 @@ export const distManifest: {
18
22
  entry: string;
19
23
  tw?: string;
20
24
  basePath?: string;
21
- } = (() => {
22
- const p = `${OUT_DIR}/manifest.json`;
23
- return existsSync(p)
24
- ? JSON.parse(readFileSync(p, "utf-8"))
25
- : { js: [], css: [], entry: "hydrate.js" };
26
- })();
25
+ /** PUBLIC_* (non-static) names declared in .env files, stamped by the build. */
26
+ publicEnv?: string[];
27
+ } = readArtifact("manifest.json") ?? { js: [], css: [], entry: "hydrate.js" };
27
28
 
28
29
  export const isDev = process.env.NODE_ENV !== "production";
29
30
  const cacheBust = isDev ? `?v=${Date.now()}` : "";
@@ -115,23 +116,19 @@ export function safeJsonForScript(data: unknown): string {
115
116
  // ─── Public Env Injection ─────────────────────────────────
116
117
 
117
118
  /**
118
- * Collect PUBLIC_* (non-static) vars that were declared in .env files.
119
- * Only exposes keys tracked by loadEnv() — never leaks system env vars
120
- * that happen to start with PUBLIC_.
119
+ * PUBLIC_* (non-static) vars declared in .env files, with their current values.
120
+ * The names come from the build (`distManifest.publicEnv`), never from
121
+ * process.env — system env vars that happen to start with PUBLIC_ don't leak.
122
+ * The server runs as its own process (and on Workers, with no .env on disk),
123
+ * so the names must travel with the build rather than in loadEnv()'s memory.
121
124
  */
122
- const _publicDynamicEnv: Record<string, string> = (() => {
123
- const declared = getDeclaredEnvKeys();
125
+ export function getPublicDynamicEnv(): Record<string, string> {
124
126
  const result: Record<string, string> = {};
125
- for (const key of declared) {
126
- if (key.startsWith("PUBLIC_") && !key.startsWith("PUBLIC_STATIC_")) {
127
- const value = process.env[key];
128
- if (value !== undefined) result[key] = value;
129
- }
127
+ for (const key of distManifest.publicEnv ?? []) {
128
+ const value = process.env[key];
129
+ if (value !== undefined) result[key] = value;
130
130
  }
131
131
  return result;
132
- })();
133
- function getPublicDynamicEnv(): Record<string, string> {
134
- return _publicDynamicEnv;
135
132
  }
136
133
 
137
134
  // ─── Lang Validation ──────────────────────────────────────
@@ -428,6 +425,13 @@ export function buildHtmlTail(
428
425
 
429
426
  const GZIP_MIN_BYTES = 2048;
430
427
 
428
+ // Off on Workers: Cloudflare's edge compresses responses itself, outside the
429
+ // worker's CPU budget. The first brotli call alone cost ~4ms of a 10ms limit.
430
+ export let compressionOn = true;
431
+ export function disableCompression(): void {
432
+ compressionOn = false;
433
+ }
434
+
431
435
  // Shared, stateless — one instance instead of a fresh allocation per response.
432
436
  const textEncoder = new TextEncoder();
433
437
 
@@ -449,8 +453,9 @@ export function compress(
449
453
  const bytes = textEncoder.encode(body);
450
454
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
451
455
  // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
452
- if (!isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
453
- return new Response(Bun.gzipSync(bytes), {
456
+ if (compressionOn && !isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
457
+ return new Response(gzipSync(bytes), {
458
+ ...PRECOMPRESSED,
454
459
  status,
455
460
  headers: { ...headers, "content-encoding": "gzip" },
456
461
  });
package/src/core/paths.ts CHANGED
@@ -2,7 +2,9 @@ import { join, dirname, delimiter } from "path";
2
2
  import { existsSync } from "fs";
3
3
 
4
4
  // This file lives at src/core/paths.ts → package root is ../..
5
- const BOSIA_PKG_DIR = join(import.meta.dir, "..", "..");
5
+ // `import.meta.dir` is undefined on Workers — no package dir and no
6
+ // node_modules there, so every lookup below just comes back empty.
7
+ const BOSIA_PKG_DIR = import.meta.dir ? join(import.meta.dir, "..", "..") : "";
6
8
 
7
9
  const NESTED_NM = join(BOSIA_PKG_DIR, "node_modules");
8
10
 
@@ -22,7 +24,7 @@ function collectAncestorNodeModules(start: string): string[] {
22
24
  return out;
23
25
  }
24
26
 
25
- const ANCESTOR_NM = collectAncestorNodeModules(dirname(BOSIA_PKG_DIR));
27
+ const ANCESTOR_NM = BOSIA_PKG_DIR ? collectAncestorNodeModules(dirname(BOSIA_PKG_DIR)) : [];
26
28
  const ALL_NM = [NESTED_NM, ...ANCESTOR_NM];
27
29
 
28
30
  /** NODE_PATH value covering nested and every ancestor node_modules */
@@ -0,0 +1,24 @@
1
+ import type { Platform } from "./hooks.ts";
2
+
3
+ // Set once at boot by the runtime entry — on Workers, the bindings. Isolate-wide,
4
+ // so events read it here instead of threading it through every loader call.
5
+ let current: Platform | undefined;
6
+
7
+ export function setPlatform(platform: Platform | undefined): void {
8
+ current = platform;
9
+ }
10
+
11
+ export function getPlatform(): Platform | undefined {
12
+ return current;
13
+ }
14
+
15
+ // True only while server.workers.ts renders `/` once at isolate startup, so V8
16
+ // compiles the render path before the first real request. That render runs no
17
+ // user hooks, loaders, metadata() or +server.ts handlers — startup has no
18
+ // bindings, and a hook caching a failed `connect()` would break the isolate —
19
+ // and never touches the response cache.
20
+ export let warmingUp = false;
21
+
22
+ export function setWarmingUp(on: boolean): void {
23
+ warmingUp = on;
24
+ }
@@ -1,3 +1,4 @@
1
+ import { builtinModules } from "module";
1
2
  import { join, dirname } from "path";
2
3
 
3
4
  import { resolveImportPath } from "./resolveImport.ts";
@@ -7,8 +8,13 @@ import { resolveImportPath } from "./resolveImport.ts";
7
8
  // bosia:routes → .bosia/routes.ts (generated route map)
8
9
  // $env → .bosia/env.server.ts (bun) or .bosia/env.client.ts (browser)
9
10
  // $* → resolved dynamically via tsconfig.json compilerOptions.paths
11
+ // ./artifacts.ts → .bosia/artifacts.ts (workers runtime — no filesystem)
12
+ // bosia:workers-runtime → .bosia/runtime.workers.ts (workers runtime)
10
13
 
11
- export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
14
+ export function makeBosiaPlugin(
15
+ target: "browser" | "bun" = "bun",
16
+ runtime: "bun" | "workers" = "bun",
17
+ ) {
12
18
  return {
13
19
  name: "bosia-resolver",
14
20
  setup(build: import("bun").PluginBuilder) {
@@ -23,6 +29,25 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
23
29
  ),
24
30
  }));
25
31
 
32
+ // Workers has no filesystem: the core's build-artifact reader becomes
33
+ // the generated module with the same JSON inlined.
34
+ if (runtime === "workers") {
35
+ build.onResolve({ filter: /^\.\/artifacts\.ts$/ }, (args) =>
36
+ args.importer.startsWith(import.meta.dir)
37
+ ? { path: join(process.cwd(), ".bosia", "artifacts.ts") }
38
+ : undefined,
39
+ );
40
+ build.onResolve({ filter: /^bosia:workers-runtime$/ }, () => ({
41
+ path: join(process.cwd(), ".bosia", "runtime.workers.ts"),
42
+ }));
43
+ // workerd provides Node builtins under nodejs_compat. Keep them external
44
+ // as `node:*`: a browser-target build would swap a bare `fs` for `{}`.
45
+ const builtins = new Set(builtinModules);
46
+ build.onResolve({ filter: /^[a-z_]+(\/[a-z_]+)?$/ }, (args) =>
47
+ builtins.has(args.path) ? { path: `node:${args.path}`, external: true } : undefined,
48
+ );
49
+ }
50
+
26
51
  // $env → .bosia/env.client.ts (browser) or .bosia/env.server.ts (bun)
27
52
  build.onResolve({ filter: /^\$env$/ }, () => ({
28
53
  path: join(
@@ -1,6 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import type { Elysia } from "elysia";
4
3
  import { createInspectorBunPlugin } from "./bun-plugin.ts";
5
4
  import { getOverlayScript } from "./overlay.ts";
6
5
  import { resolveFrame, resolveStack } from "./sourcemap.ts";
@@ -142,7 +141,7 @@ function installProcessListeners() {
142
141
  // live in a separately-bundled module graph from this disk-loaded plugin —
143
142
  // can notify the inspector without an import dependency. Renderer catches
144
143
  // (page load, layout load, SSR load) swallow errors and return a rendered
145
- // error page, so they never reach Elysia `.onError()` on their own. The
144
+ // error page, so they never reach the backend `.onError()` on their own. The
146
145
  // global is typed in core/devErrorReport.ts.
147
146
  function installGlobalReporter() {
148
147
  globalThis.__BOSIA_REPORT_ERROR__ = (e) => {
@@ -179,10 +178,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
179
178
 
180
179
  backend: {
181
180
  before(app) {
182
- // Cast to the base Elysia type — chaining .post/.get/.onError narrows
183
- // the generic so the plugin return type drifts unless we widen back.
184
- let chained: Elysia = app as unknown as Elysia;
185
- chained = chained.post(endpoint, async ({ body }: { body: unknown }) => {
181
+ let chained = app.post(endpoint, async ({ body }) => {
186
182
  const data = (body ?? {}) as {
187
183
  file?: string;
188
184
  line?: number;
@@ -270,7 +266,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
270
266
  installProcessListeners();
271
267
  installGlobalReporter();
272
268
 
273
- // Elysia onError — runs because server.ts registers plugin
269
+ // Backend onError — runs because server.ts registers plugin
274
270
  // backend.before() ahead of the base .onError() responder.
275
271
  chained = chained.onError(({ error }) => {
276
272
  const e = error as Error;
@@ -281,12 +277,12 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
281
277
  });
282
278
  // Fall through to the base handler.
283
279
  return undefined;
284
- }) as unknown as Elysia;
280
+ });
285
281
 
286
282
  // Live SSE stream. New clients also get a flush of the bounded
287
283
  // replay buffer so errors that fired during a failing render
288
284
  // (before the 500 page's overlay could subscribe) are visible.
289
- chained = chained.get("/__bosia/errors", ({ request }: { request: Request }) => {
285
+ chained = chained.get("/__bosia/errors", ({ request }) => {
290
286
  const stream = new ReadableStream<Uint8Array>({
291
287
  start(ctrl) {
292
288
  sseClients.add(ctrl);
@@ -322,7 +318,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
322
318
  Connection: "keep-alive",
323
319
  },
324
320
  });
325
- }) as unknown as Elysia;
321
+ });
326
322
  }
327
323
 
328
324
  return chained;
@@ -39,7 +39,7 @@ export function serverTiming(options: ServerTimingOptions = {}): BosiaPlugin {
39
39
  return;
40
40
  }
41
41
 
42
- // Otherwise, push into Elysia's outbound header bag.
42
+ // Otherwise, push into the outbound header bag.
43
43
  const headers = (set.headers ??= {}) as Record<string, string>;
44
44
  headers[headerName] = value;
45
45
  });
@@ -5,6 +5,7 @@ import type { RouteManifest, TrailingSlash } from "./types.ts";
5
5
 
6
6
  import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
7
7
  import { currentBase } from "./appBase.ts";
8
+ import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
8
9
 
9
10
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
10
11
  export function getEphemeralPort(): Promise<number> {
@@ -129,11 +130,10 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
129
130
  if (!route.pageServer) return [];
130
131
  const filePath = join("src", "routes", route.pageServer);
131
132
  const content = await Bun.file(filePath).text();
132
- if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
133
- if (/export\s+const\s+ssr\s*=\s*false/.test(content)) {
134
- console.warn(
135
- ` ⚠️ ${route.pattern} has prerender=true && ssr=false — contradictory, skipped`,
136
- );
133
+ if (!wantsPrerender(content)) return [];
134
+ const skip = prerenderSkipReason(content);
135
+ if (skip) {
136
+ console.warn(` ⚠️ ${route.pattern} has prerender=true && ${skip}, skipped`);
137
137
  return [];
138
138
  }
139
139
  const ts = route.trailingSlash;
@@ -144,7 +144,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
144
144
  const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
145
145
  const filePath = join("src", "routes", route.server);
146
146
  const content = await Bun.file(filePath).text();
147
- if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
147
+ if (!wantsPrerender(content)) return [];
148
148
  if (route.pattern.includes("["))
149
149
  return expandDynamicRoute(route.pattern, filePath, "api", "never");
150
150
  return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
@@ -36,6 +36,7 @@ import {
36
36
  } from "./html.ts";
37
37
  import type { Metadata } from "./hooks.ts";
38
38
  import { loadPlugins } from "./config.ts";
39
+ import { getPlatform, warmingUp } from "./platform.ts";
39
40
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
40
41
  import { dev500Response } from "./dev-500.ts";
41
42
  import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
@@ -419,7 +420,7 @@ export async function loadRouteData(
419
420
  continue;
420
421
  }
421
422
  const mod = await ls.loader();
422
- if (typeof mod.load === "function") {
423
+ if (typeof mod.load === "function" && !warmingUp) {
423
424
  // Snapshot per layer so loaders cannot mutate the shared accumulator,
424
425
  // preserving the same isolation semantics as the previous merge-on-call code.
425
426
  const snapshot = { ...parentData };
@@ -436,6 +437,7 @@ export async function loadRouteData(
436
437
  fetch: trackedFetch(fetch, origin, deps),
437
438
  metadata: null,
438
439
  depends: makeDepends(deps),
440
+ platform: getPlatform(),
439
441
  setHeaders,
440
442
  }),
441
443
  LOAD_TIMEOUT,
@@ -487,7 +489,7 @@ export async function loadRouteData(
487
489
  if (skipPage) {
488
490
  pageData = null;
489
491
  pageDeps = null;
490
- } else if (typeof mod.load === "function") {
492
+ } else if (typeof mod.load === "function" && !warmingUp) {
491
493
  const snapshot = { ...parentData };
492
494
  const parent = async () => snapshot;
493
495
  const deps = emptyDeps();
@@ -502,6 +504,7 @@ export async function loadRouteData(
502
504
  fetch: trackedFetch(fetch, origin, deps),
503
505
  metadata: metadataData,
504
506
  depends: makeDepends(deps),
507
+ platform: getPlatform(),
505
508
  setHeaders,
506
509
  }),
507
510
  LOAD_TIMEOUT,
@@ -560,11 +563,11 @@ export async function loadMetadata(
560
563
  if (!route.pageServer) return null;
561
564
  try {
562
565
  const mod = await route.pageServer();
563
- if (typeof mod.metadata === "function") {
566
+ if (typeof mod.metadata === "function" && !warmingUp) {
564
567
  const fetch = makeFetch(req, url);
565
568
  return (
566
569
  (await withTimeout(
567
- mod.metadata({ params, url, locals, cookies, fetch }),
570
+ mod.metadata({ params, url, locals, cookies, fetch, platform: getPlatform() }),
568
571
  METADATA_TIMEOUT,
569
572
  `metadata (${url.pathname})`,
570
573
  )) ?? null
@@ -618,7 +621,8 @@ export async function renderSSRStream(
618
621
  // into the cached HTML but the CSP header is re-derived each request, so a
619
622
  // cached page would ship with a dead nonce and the browser would block its
620
623
  // inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
621
- const cacheable = CACHE_ENABLED && !CSP_ENABLED && routeCacheable && req.method === "GET";
624
+ const cacheable =
625
+ CACHE_ENABLED && !CSP_ENABLED && !warmingUp && routeCacheable && req.method === "GET";
622
626
  let cacheKey: string | null = null;
623
627
  let releaseMiss: (() => void) | null = null;
624
628
  if (cacheable) {
@@ -652,7 +656,8 @@ export async function renderSSRStream(
652
656
  metadata = await loadMetadata(route, params, url, locals, cookies, req);
653
657
  } catch (err) {
654
658
  if (isRedirect(err)) {
655
- return Response.redirect(err.location, err.status);
659
+ // Not Response.redirect(): it rejects relative URLs on Workers.
660
+ return new Response(null, { status: err.status, headers: { Location: err.location } });
656
661
  }
657
662
  if (isHttpError(err)) {
658
663
  return renderErrorPage(
@@ -690,7 +695,8 @@ export async function renderSSRStream(
690
695
  ]);
691
696
  pageMod = pm;
692
697
  } catch (err) {
693
- if (isRedirect(err)) return Response.redirect(err.location, err.status);
698
+ if (isRedirect(err))
699
+ return new Response(null, { status: err.status, headers: { Location: err.location } });
694
700
  if (isHttpError(err)) {
695
701
  const e = err as HttpError & {
696
702
  errorDepth?: number;
@@ -950,7 +956,8 @@ export async function renderPageWithFormData(
950
956
  try {
951
957
  metadata = await loadMetadata(route, params, url, locals, cookies, req);
952
958
  } catch (err) {
953
- if (isRedirect(err)) return Response.redirect(err.location, err.status);
959
+ if (isRedirect(err))
960
+ return new Response(null, { status: err.status, headers: { Location: err.location } });
954
961
  if (isHttpError(err)) {
955
962
  return renderErrorPage(
956
963
  err.status,
@@ -68,6 +68,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
68
68
  lines.push(" layoutIds: (string | null)[];");
69
69
  lines.push(" loading: (() => Promise<any>) | null;");
70
70
  lines.push(" layoutPaths: string[];");
71
+ lines.push(" prerender: boolean;");
71
72
  lines.push("}> = [");
72
73
  for (const r of pages) {
73
74
  const layoutImports = r.layouts
@@ -102,6 +103,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
102
103
  ` loading: ${r.loading ? `() => import(${JSON.stringify(toImportPath(r.loading))})` : "null"},`,
103
104
  );
104
105
  lines.push(` layoutPaths: ${JSON.stringify(r.layouts)},`);
106
+ lines.push(` prerender: ${r.prerender},`);
105
107
  lines.push(" },");
106
108
  }
107
109
  lines.push("];\n");
@@ -204,6 +206,7 @@ function generateClientRoutesFile(
204
206
  lines.push(" layoutIds: (string | null)[];");
205
207
  lines.push(" loading: (() => Promise<any>) | null;");
206
208
  lines.push(" layoutPaths: string[];");
209
+ lines.push(" prerender: boolean;");
207
210
  lines.push("}> = [");
208
211
  for (const r of pages) {
209
212
  const layoutImports = r.layouts
@@ -235,6 +238,7 @@ function generateClientRoutesFile(
235
238
  ` loading: ${r.loading ? `() => import(${JSON.stringify(toImportPath(r.loading))})` : "null"},`,
236
239
  );
237
240
  lines.push(` layoutPaths: ${JSON.stringify(r.layouts)},`);
241
+ lines.push(` prerender: ${r.prerender},`);
238
242
  lines.push(" },");
239
243
  }
240
244
  lines.push("];\n");
@@ -42,6 +42,35 @@ function readPageCache(filePath: string): boolean | null {
42
42
  }
43
43
  }
44
44
 
45
+ /** Literal `export const prerender = true` in a server module's source. */
46
+ export function wantsPrerender(src: string): boolean {
47
+ return /export\s+const\s+prerender\s*=\s*true/.test(src);
48
+ }
49
+
50
+ /** Why a page that asks for prerender can't have it, or null when it can. */
51
+ export function prerenderSkipReason(src: string): string | null {
52
+ if (/export\s+const\s+ssr\s*=\s*false/.test(src)) return "ssr=false — contradictory";
53
+ // A static file can't run an action: Bun would answer the POST with the
54
+ // prerendered HTML, Workers' asset server with 405. Render it live instead.
55
+ if (
56
+ /export\s+(const|let|var|async\s+function|function)\s+actions\b|export\s*\{[^}]*\bactions\b/.test(
57
+ src,
58
+ )
59
+ )
60
+ return "actions — forms need a live page";
61
+ return null;
62
+ }
63
+
64
+ /** True when the page will really be prerendered — the client fetches its data as a static file. */
65
+ function readPrerender(filePath: string): boolean {
66
+ try {
67
+ const src = readFileSync(filePath, "utf-8");
68
+ return wantsPrerender(src) && prerenderSkipReason(src) === null;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
45
74
  function readTrailingSlash(filePath: string): TrailingSlash | null {
46
75
  try {
47
76
  const src = readFileSync(filePath, "utf-8");
@@ -137,6 +166,7 @@ export function scanRoutes(): RouteManifest {
137
166
  errorPages: [...currentErrorPages],
138
167
  trailingSlash: effectiveTs,
139
168
  cache: readPageCache(join(ROUTES_DIR, pageFile)),
169
+ prerender: pageServerFile ? readPrerender(join(ROUTES_DIR, pageServerFile)) : false,
140
170
  });
141
171
  }
142
172