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.
- package/README.md +3 -3
- package/package.json +2 -4
- package/src/ambient.d.ts +7 -0
- package/src/cli/build.ts +6 -1
- package/src/cli/index.ts +3 -3
- package/src/cli/start.ts +9 -1
- package/src/core/appHtml.ts +3 -11
- package/src/core/artifacts.ts +21 -0
- package/src/core/backend.ts +238 -0
- package/src/core/build.ts +97 -6
- package/src/core/cache.ts +10 -6
- package/src/core/client/App.svelte +5 -2
- package/src/core/client/prefetch.ts +3 -1
- package/src/core/config.ts +20 -9
- package/src/core/dev.ts +2 -3
- package/src/core/env.ts +0 -20
- package/src/core/hooks.ts +28 -0
- package/src/core/html.ts +29 -24
- package/src/core/paths.ts +4 -2
- package/src/core/platform.ts +24 -0
- package/src/core/plugin.ts +26 -1
- package/src/core/plugins/inspector/index.ts +6 -10
- package/src/core/plugins/server-timing.ts +1 -1
- package/src/core/prerender.ts +6 -6
- package/src/core/renderer.ts +15 -8
- package/src/core/routeFile.ts +4 -0
- package/src/core/scanner.ts +30 -0
- package/src/core/server.bun.ts +129 -0
- package/src/core/server.ts +108 -204
- package/src/core/server.workers.ts +46 -0
- package/src/core/types/plugin.ts +10 -6
- package/src/core/types.ts +2 -0
- package/src/core/workersCodegen.ts +110 -0
- package/src/core/workersGuard.ts +107 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/server.ts +1 -0
- package/templates/default/README.md +1 -1
- package/templates/default/_gitignore +2 -0
- package/templates/default/src/routes/(public)/+page.svelte +1 -1
- package/templates/demo/_gitignore +2 -0
- package/templates/demo/src/routes/(public)/+page.svelte +2 -2
- package/templates/demo/src/routes/(public)/about/+page.svelte +2 -2
- package/templates/shop/_gitignore +2 -0
- 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
|
-
|
|
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;
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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
|
-
|
|
94
|
-
|
|
99
|
+
return { ...config, plugins: rawPlugins.filter((p): p is BosiaPlugin => Boolean(p)) };
|
|
100
|
+
}
|
|
95
101
|
|
|
96
|
-
|
|
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
|
|
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
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
123
|
-
const declared = getDeclaredEnvKeys();
|
|
125
|
+
export function getPublicDynamicEnv(): Record<string, string> {
|
|
124
126
|
const result: Record<string, string> = {};
|
|
125
|
-
for (const key of
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/plugin.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
})
|
|
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 }
|
|
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
|
-
})
|
|
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
|
|
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
|
});
|
package/src/core/prerender.ts
CHANGED
|
@@ -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 (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
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" }];
|
package/src/core/renderer.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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))
|
|
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))
|
|
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,
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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");
|
package/src/core/scanner.ts
CHANGED
|
@@ -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
|
|