bosia 1.1.4 → 1.1.6

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.
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "fs";
2
2
  import { basename, join, resolve as resolvePath } from "path";
3
3
  import { PRECOMPRESSED, pickEncoding } from "./html.ts";
4
4
  import { AssetCache, assetCache } from "./assetCache.ts";
5
+ import { matchesEtag } from "./etag.ts";
5
6
 
6
7
  /** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
7
8
  export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
@@ -11,10 +12,63 @@ const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
11
12
  const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
12
13
  const DEFAULT_CACHE = "no-cache";
13
14
 
14
- // Files/dirs at OUT_DIR root that the manifest must not surface — they're either
15
- // build metadata or re-merges already covered by the per-root walks.
16
- const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server"]);
17
- const OUT_DIR_SKIP_FILES = new Set(["manifest.json", "route-manifest.json"]);
15
+ // Files/dirs at OUT_DIR root that must never be served — server code, build
16
+ // metadata, or re-merges already covered by the per-root walks. `server/` and
17
+ // `worker/` are server bundles; `hooks.server.js` and `bosia.config.js` are the
18
+ // user's own server code bundled for the runtime (with their dev `.map`s).
19
+ const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server", "worker"]);
20
+ const OUT_DIR_SKIP_FILES = new Set([
21
+ "manifest.json",
22
+ "route-manifest.json",
23
+ "app-html.json",
24
+ "svelte-maps.json",
25
+ "hooks.server.js",
26
+ "hooks.server.js.map",
27
+ "bosia.config.js",
28
+ "bosia.config.js.map",
29
+ ]);
30
+
31
+ /**
32
+ * Whether `rel` (a path relative to OUT_DIR, `/`-separated, no leading slash)
33
+ * is build output the server must not hand to a browser. Shared by the prod
34
+ * manifest and the dev per-request fallthrough so both refuse the same files.
35
+ */
36
+ export function isPrivateOutPath(rel: string): boolean {
37
+ const slash = rel.indexOf("/");
38
+ return slash === -1 ? OUT_DIR_SKIP_FILES.has(rel) : OUT_DIR_SKIP_DIRS.has(rel.slice(0, slash));
39
+ }
40
+
41
+ /**
42
+ * Dev-mode lookup of a user file at the OUT_DIR root (the prod manifest covers
43
+ * this in production). `urlPath` is the decoded request path. Each segment must
44
+ * equal, exactly, a name `readdir` returns, and the joined real names must not
45
+ * be private. Matching the filesystem's own names instead of comparing request
46
+ * strings means no other spelling of a private file can get through: a
47
+ * different case, a trailing dot or space (Windows), an NTFS `::$DATA` stream,
48
+ * an 8.3 short name, or a Unicode case-folding twin like `ſerver` (macOS).
49
+ * Returns the absolute path, or null.
50
+ */
51
+ export function findOutDirFile(outDir: string, urlPath: string): string | null {
52
+ const segments = urlPath.split("/").filter(Boolean);
53
+ if (segments.length === 0) return null;
54
+ if (isPrivateOutPath(segments.join("/"))) return null;
55
+ let dir = resolvePath(outDir);
56
+ for (let i = 0; i < segments.length; i++) {
57
+ let names: string[];
58
+ try {
59
+ names = readdirSync(dir);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (!names.includes(segments[i]!)) return null;
64
+ dir = join(dir, segments[i]!);
65
+ }
66
+ try {
67
+ return statSync(dir).isFile() ? dir : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
18
72
 
19
73
  const RESERVED_PREFIX = "/__bosia/";
20
74
 
@@ -94,13 +148,13 @@ export function buildStaticManifest(outDir: string): StaticManifest {
94
148
  }
95
149
  for (const ent of rootEntries) {
96
150
  if (ent.isDirectory()) {
97
- if (OUT_DIR_SKIP_DIRS.has(ent.name)) continue;
151
+ if (isPrivateOutPath(`${ent.name}/`)) continue;
98
152
  const sub = join(outAbs, ent.name);
99
153
  for (const { abs, rel } of walk(sub, ent.name)) {
100
154
  addOnce(manifest, `/${rel}`, { absPath: abs });
101
155
  }
102
156
  } else if (ent.isFile()) {
103
- if (OUT_DIR_SKIP_FILES.has(ent.name)) continue;
157
+ if (isPrivateOutPath(ent.name)) continue;
104
158
  addOnce(manifest, `/${ent.name}`, { absPath: join(outAbs, ent.name) });
105
159
  }
106
160
  }
@@ -215,15 +269,6 @@ function etagFor(path: string, encoding: string | undefined): string | null {
215
269
  return tag;
216
270
  }
217
271
 
218
- function matchesEtag(header: string | null, etag: string): boolean {
219
- if (!header) return false;
220
- for (const raw of header.split(",")) {
221
- const t = raw.trim();
222
- if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
223
- }
224
- return false;
225
- }
226
-
227
272
  export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
228
273
  const raw = urlPath.split("?")[0];
229
274
  // Manifest keys are raw filenames; URLs arrive percent-encoded.
@@ -1,4 +1,6 @@
1
- import { compile, compileModule } from "svelte/compiler";
1
+ import { mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { compile, compileModule, parse, VERSION as SVELTE_VERSION } from "svelte/compiler";
2
4
  import type { BunPlugin } from "bun";
3
5
 
4
6
  import { auditSvelteSource } from "./svelteAudit.ts";
@@ -95,6 +97,56 @@ export function rebaseSvelteMarkup(source: string): string {
95
97
  );
96
98
  }
97
99
 
100
+ // ─── Dev compile cache ───────────────────────────────────
101
+ // Every dev rebuild is a fresh `bun run build.ts`, so each one used to compile
102
+ // every component twice (client + server) from scratch — most of the bundle
103
+ // step. `bosia dev` points BOSIA_SVELTE_CACHE_DIR at a directory it clears on
104
+ // start; a component whose source and compile inputs are unchanged reuses the
105
+ // stored output. Production builds never set it.
106
+ //
107
+ // The import audit still runs on every build: it reads other files' exports,
108
+ // so its verdict can change while this file doesn't. A hit re-parses the
109
+ // source for it (~10x cheaper than a compile) and replays stored warnings.
110
+
111
+ // Bump when the cached shape or anything feeding `compile()` below changes.
112
+ const COMPILE_CACHE_VERSION = 1;
113
+
114
+ type CompileWarning = { code: string; message: string; start?: { line: number; column: number } };
115
+ type CachedCompile = { code: string; map: unknown; css: string | null; warnings: CompileWarning[] };
116
+
117
+ let madeDir: string | null = null;
118
+ function compileCacheDir(): string | null {
119
+ const dir = process.env.BOSIA_SVELTE_CACHE_DIR || null;
120
+ if (dir && dir !== madeDir) {
121
+ mkdirSync(dir, { recursive: true });
122
+ madeDir = dir;
123
+ }
124
+ return dir;
125
+ }
126
+
127
+ /**
128
+ * `build()`'s result, from the dev compile cache when `key` was seen before in
129
+ * this `bosia dev` session. Without a cache dir (every non-dev build) it just
130
+ * calls `build()`. `key` must cover every input of the compile; the Svelte
131
+ * version and COMPILE_CACHE_VERSION are added here. The value must be JSON.
132
+ */
133
+ export async function cachedCompile<T>(
134
+ key: unknown[],
135
+ build: () => T,
136
+ ): Promise<{ value: T; hit: boolean }> {
137
+ const dir = compileCacheDir();
138
+ if (!dir) return { value: build(), hit: false };
139
+ const hash = Bun.hash(JSON.stringify([COMPILE_CACHE_VERSION, SVELTE_VERSION, ...key]));
140
+ const path = join(dir, `${hash.toString(36)}.json`);
141
+ try {
142
+ return { value: (await Bun.file(path).json()) as T, hit: true };
143
+ } catch {
144
+ const value = build();
145
+ await Bun.write(path, JSON.stringify(value));
146
+ return { value, hit: false };
147
+ }
148
+ }
149
+
98
150
  export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
99
151
  const generate = target === "browser" ? "client" : "server";
100
152
  const dev = process.env.NODE_ENV !== "production";
@@ -109,41 +161,59 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
109
161
 
110
162
  build.onLoad({ filter: /\.svelte$/ }, async (args) => {
111
163
  const source = await Bun.file(args.path).text();
112
- const result = compile(rebaseSvelteMarkup(source), {
113
- generate,
114
- // External on both targets. The browser used to get "injected",
115
- // which put every scoped rule inside the JS bundle — so an
116
- // SSR'd page painted before its own layout CSS existed and
117
- // snapped into place at hydration. `collectComponentCss` below
118
- // gathers the rules into one stylesheet the head can link.
119
- css: "external",
120
- dev,
121
- hmr: false,
122
- cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
123
- filename: args.path,
124
- // Modern AST shape (Svelte 5.x) — `fragment`, `instance`, `module`
125
- // rather than the legacy `html`. The audit walker assumes modern.
126
- modernAst: true,
127
- });
164
+ const rebased = rebaseSvelteMarkup(source);
165
+ // Set on a fresh compile; a cache hit re-parses for the audit.
166
+ let ast: unknown = undefined;
167
+ const { value: compiled } = await cachedCompile<CachedCompile>(
168
+ ["bosia", args.path, generate, dev, rebased],
169
+ () => {
170
+ const result = compile(rebased, {
171
+ generate,
172
+ // External on both targets. The browser used to get "injected",
173
+ // which put every scoped rule inside the JS bundle — so an
174
+ // SSR'd page painted before its own layout CSS existed and
175
+ // snapped into place at hydration. `collectComponentCss` below
176
+ // gathers the rules into one stylesheet the head can link.
177
+ css: "external",
178
+ dev,
179
+ hmr: false,
180
+ cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
181
+ filename: args.path,
182
+ // Modern AST shape (Svelte 5.x) — `fragment`, `instance`, `module`
183
+ // rather than the legacy `html`. The audit walker assumes modern.
184
+ modernAst: true,
185
+ });
186
+ ast = (result as unknown as { ast?: unknown }).ast;
187
+ return {
188
+ code: result.js.code,
189
+ map: typeof result.js.map === "string" ? JSON.parse(result.js.map) : result.js.map,
190
+ css: result.css?.code ?? null,
191
+ warnings: (result.warnings ?? []).map((w) => ({
192
+ code: w.code,
193
+ message: w.message,
194
+ start: w.start ? { line: w.start.line, column: w.start.column } : undefined,
195
+ })),
196
+ };
197
+ },
198
+ );
128
199
  // Browser only: both plugin instances share module state and the
129
200
  // client and server builds run concurrently, so collecting from
130
201
  // each would emit every rule twice.
131
- if (target === "browser" && result.css?.code) {
132
- collectComponentCss(args.path, result.css.code);
202
+ if (target === "browser" && compiled.css) {
203
+ collectComponentCss(args.path, compiled.css);
133
204
  }
134
205
  const existing = auditInflight.get(args.path);
135
206
  if (existing) {
136
207
  await existing;
137
208
  } else {
209
+ const warnings = compiled.warnings;
138
210
  const promise = (async () => {
139
211
  const strict = await getStrictImportsOption();
140
212
  const failure = await auditSvelteSource({
141
213
  source,
142
214
  filename: args.path,
143
- ast: (result as unknown as { ast?: unknown }).ast,
144
- warnings: (result.warnings ?? []) as unknown as Parameters<
145
- typeof auditSvelteSource
146
- >[0]["warnings"],
215
+ ast: ast ?? parse(rebased, { modern: true }),
216
+ warnings: warnings as unknown as Parameters<typeof auditSvelteSource>[0]["warnings"],
147
217
  cwd: process.cwd(),
148
218
  exportCache: auditExportCache,
149
219
  strict,
@@ -157,11 +227,10 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
157
227
  // resolver — browser-side stack frames are what we need to translate.
158
228
  // Server (Bun) compile output has different line numbers and would
159
229
  // clobber the client entry under the same cache key.
160
- if (dev && target === "browser" && result.js.map) {
161
- const m = typeof result.js.map === "string" ? JSON.parse(result.js.map) : result.js.map;
162
- svelteMapCache.set(args.path, m);
230
+ if (dev && target === "browser" && compiled.map) {
231
+ svelteMapCache.set(args.path, compiled.map);
163
232
  }
164
- const contents = dev ? fixBindShadow(result.js.code) : result.js.code;
233
+ const contents = dev ? fixBindShadow(compiled.code) : compiled.code;
165
234
  return { contents, loader: "ts" };
166
235
  });
167
236
 
package/src/core/types.ts CHANGED
@@ -7,6 +7,8 @@ export type TrailingSlash = "never" | "always" | "ignore";
7
7
  export interface PageRoute {
8
8
  /** URL pattern, e.g. "/" or "/blog/[slug]" or "/[...rest]" */
9
9
  pattern: string;
10
+ /** Folder path under src/routes with groups kept, e.g. "/(private)/blog/[slug]" */
11
+ id: string;
10
12
  /** Path to +page.svelte, relative to src/routes/ */
11
13
  page: string;
12
14
  /** Chain of +layout.svelte paths root → leaf, relative to src/routes/ */
@@ -42,6 +44,8 @@ export interface PageRoute {
42
44
  export interface ApiRoute {
43
45
  /** URL pattern, e.g. "/api/hello" or "/api/users/[id]" */
44
46
  pattern: string;
47
+ /** Folder path under src/routes with groups kept, e.g. "/api/(admin)/users" */
48
+ id: string;
45
49
  /** Path to +server.ts, relative to src/routes/ */
46
50
  server: string;
47
51
  }
@@ -1,6 +1,9 @@
1
1
  DATABASE_URL=sqlite://./data/app.db
2
2
 
3
3
  SESSION_SECRET=change-me-in-production
4
+ # The response cache tells users apart by these cookies. Keep bosia_session
5
+ # (the sign-in cookie) here, or one user's page can be served to another.
6
+ CACHE_KEYS=session,sid,auth,token,jwt,Authorization,bosia_session
4
7
 
5
8
  STORAGE_DRIVER=s3
6
9
  S3_BUCKET=
@@ -1,4 +1,4 @@
1
- import { sequence } from "bosia";
1
+ import { redirect, sequence } from "bosia";
2
2
  import type { Handle } from "bosia";
3
3
  import { db } from "./features/drizzle";
4
4
  import { authHandle } from "./features/auth";
@@ -8,6 +8,16 @@ const dbHandle: Handle = async ({ event, resolve }) => {
8
8
  return resolve(event);
9
9
  };
10
10
 
11
+ // Every page under (private) needs a signed-in user. Checked here, by route id,
12
+ // before metadata() or any loader runs — on page loads, client navigations and forms.
13
+ const guardHandle: Handle = async ({ event, resolve }) => {
14
+ if (event.route.id?.startsWith("/(private)") && !event.locals.user) {
15
+ const next = encodeURIComponent(event.url.pathname + event.url.search);
16
+ throw redirect(303, `/login?next=${next}`);
17
+ }
18
+ return resolve(event);
19
+ };
20
+
11
21
  const loggingHandle: Handle = async ({ event, resolve }) => {
12
22
  const start = Date.now();
13
23
  event.locals.requestTime = start;
@@ -18,4 +28,4 @@ const loggingHandle: Handle = async ({ event, resolve }) => {
18
28
  return res;
19
29
  };
20
30
 
21
- export const handle = sequence(dbHandle, authHandle, loggingHandle);
31
+ export const handle = sequence(dbHandle, authHandle, guardHandle, loggingHandle);
@@ -1,10 +1,6 @@
1
- import { redirect } from "bosia";
2
1
  import type { LoadEvent } from "bosia";
3
2
 
4
- export async function load({ locals, url }: LoadEvent) {
5
- if (!locals.user) {
6
- const next = encodeURIComponent(url.pathname + url.search);
7
- throw redirect(303, `/login?next=${next}`);
8
- }
3
+ // Sign-in is checked in hooks.server.ts, before this loader runs.
4
+ export async function load({ locals }: LoadEvent) {
9
5
  return { user: locals.user };
10
6
  }
@@ -1,6 +1,9 @@
1
1
  DATABASE_URL=postgres://postgres:postgres@localhost:5432/store
2
2
 
3
3
  SESSION_SECRET=change-me-in-production
4
+ # The response cache tells users apart by these cookies. Keep bosia_session
5
+ # (the sign-in cookie) here, or one user's page can be served to another.
6
+ CACHE_KEYS=session,sid,auth,token,jwt,Authorization,bosia_session
4
7
 
5
8
  STORAGE_DRIVER=s3
6
9
  S3_BUCKET=uploads
@@ -1,4 +1,4 @@
1
- import { sequence } from "bosia";
1
+ import { redirect, sequence } from "bosia";
2
2
  import type { Handle } from "bosia";
3
3
  import { db } from "./features/drizzle";
4
4
  import { authHandle } from "./features/auth";
@@ -8,6 +8,16 @@ const dbHandle: Handle = async ({ event, resolve }) => {
8
8
  return resolve(event);
9
9
  };
10
10
 
11
+ // Every page under (private) needs a signed-in user. Checked here, by route id,
12
+ // before metadata() or any loader runs — on page loads, client navigations and forms.
13
+ const guardHandle: Handle = async ({ event, resolve }) => {
14
+ if (event.route.id?.startsWith("/(private)") && !event.locals.user) {
15
+ const next = encodeURIComponent(event.url.pathname + event.url.search);
16
+ throw redirect(303, `/login?next=${next}`);
17
+ }
18
+ return resolve(event);
19
+ };
20
+
11
21
  const loggingHandle: Handle = async ({ event, resolve }) => {
12
22
  const start = Date.now();
13
23
  event.locals.requestTime = start;
@@ -18,4 +28,4 @@ const loggingHandle: Handle = async ({ event, resolve }) => {
18
28
  return res;
19
29
  };
20
30
 
21
- export const handle = sequence(dbHandle, authHandle, loggingHandle);
31
+ export const handle = sequence(dbHandle, authHandle, guardHandle, loggingHandle);
@@ -1,10 +1,6 @@
1
- import { redirect } from "bosia";
2
1
  import type { LoadEvent } from "bosia";
3
2
 
4
- export async function load({ locals, url }: LoadEvent) {
5
- if (!locals.user) {
6
- const next = encodeURIComponent(url.pathname + url.search);
7
- throw redirect(303, `/login?next=${next}`);
8
- }
3
+ // Sign-in is checked in hooks.server.ts, before this loader runs.
4
+ export async function load({ locals }: LoadEvent) {
9
5
  return { user: locals.user };
10
6
  }