bosia 0.8.16 → 0.9.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.8.16",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -0,0 +1,25 @@
1
+ import { normalizeBase } from "./basePath.ts";
2
+
3
+ // The mounted base, resolved from whichever side of the wire is asking. Kept
4
+ // out of paths.ts because that module reaches for `fs`, and this one has to
5
+ // survive being pulled into a client bundle by `errors.ts`.
6
+ //
7
+ // Memoized on first use rather than at import: the CLI sets `BASE_PATH` after
8
+ // these modules are already loaded, and tests need to move it between cases.
9
+ let cached: string | null = null;
10
+
11
+ export function currentBase(): string {
12
+ if (cached === null) {
13
+ cached = normalizeBase(
14
+ typeof window !== "undefined"
15
+ ? (window as unknown as { __BOSIA_BASE__?: string }).__BOSIA_BASE__
16
+ : process.env.BASE_PATH,
17
+ );
18
+ }
19
+ return cached;
20
+ }
21
+
22
+ /** Test seam — the base is memoized per process. */
23
+ export function resetBaseCache(): void {
24
+ cached = null;
25
+ }
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
 
4
4
  import { OUT_DIR } from "./paths.ts";
5
+ import { rebaseHtmlAttrs } from "./basePath.ts";
6
+ import { currentBase } from "./appBase.ts";
5
7
 
6
8
  // ─── Types ────────────────────────────────────────────────
7
9
 
@@ -128,5 +130,9 @@ export function interpolateSegment(
128
130
  result = result.replaceAll("%bosia.nonce%", vars.nonce);
129
131
  }
130
132
 
131
- return result;
133
+ // src/app.html is app-authored markup like any other, so a font preload or a
134
+ // favicon written as href="/fonts/…" gets rebased here too. Without this it is
135
+ // the one URL that still escapes the mount, and it fails silently: the page
136
+ // renders, just with the fallback font.
137
+ return rebaseHtmlAttrs(currentBase(), result);
132
138
  }
@@ -0,0 +1,94 @@
1
+ // Mounting an app under a URL prefix — `BASE_PATH=/sso` serves the whole app
2
+ // from https://host/sso/… instead of the origin root.
3
+ //
4
+ // Env-free and pure on purpose. The server reads `process.env.BASE_PATH`, the
5
+ // browser reads `window.__BOSIA_BASE__`, and both call these same functions, so
6
+ // the two halves of the router cannot drift apart. That matters more than it
7
+ // looks: a mismatch means the server renders a page the client router then
8
+ // fails to match, and the app hydrates into a blank screen.
9
+
10
+ /** `""` for a root-mounted app, else a leading slash and no trailing one. */
11
+ export function normalizeBase(raw: string | null | undefined): string {
12
+ const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
13
+ if (!trimmed || trimmed === "/") return "";
14
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
15
+ }
16
+
17
+ /**
18
+ * Prefix a root-absolute in-app path. Everything else is returned untouched — a
19
+ * full URL, a protocol-relative `//host`, a relative path, and a path already
20
+ * under the base, which makes this safe to apply twice.
21
+ *
22
+ * The last clause needs the `/` in `${base}/`: without it a base of `/sso` would
23
+ * swallow a sibling route `/sso-admin`. It does mean an app mounted at `/sso`
24
+ * that also has its own `/sso/…` route gets left alone — pathological, and the
25
+ * alternative is a prefix that is not idempotent.
26
+ */
27
+ export function withBase(base: string, path: string): string {
28
+ if (!base || !path.startsWith("/") || path.startsWith("//")) return path;
29
+ if (path === base || path.startsWith(`${base}/`)) return path;
30
+ return base + path;
31
+ }
32
+
33
+ /**
34
+ * The in-app pathname for an incoming request, or `null` when the request is
35
+ * not ours to answer. `/sso` and `/sso/` both resolve to `/` so the root route
36
+ * is reachable with or without the trailing slash.
37
+ */
38
+ export function stripBase(base: string, pathname: string): string | null {
39
+ if (!base) return pathname;
40
+ if (pathname === base) return "/";
41
+ if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
42
+ return null;
43
+ }
44
+
45
+ // Root-absolute URLs in the attributes a browser resolves against the origin.
46
+ // `(?!\/)` keeps protocol-relative `//host` out of it.
47
+ const ROOT_ABSOLUTE_ATTR = /\b(href|src|action|formaction)=("|')(\/(?!\/)[^"']*)\2/gi;
48
+
49
+ // CSS `url(/…)`, which reaches the origin exactly like an href does. Lives in
50
+ // `style` attributes (where the quotes arrive HTML-escaped as `"`) and in
51
+ // `<style>` blocks. A mask-image that silently 404s is a blank icon, not an
52
+ // error, so this one is easy to miss.
53
+ const CSS_URL_ROOT = /(url\(\s*(?:&quot;|&#39;|&apos;|["'])?)(\/(?!\/)[^)"'&\s]*)/gi;
54
+
55
+ /**
56
+ * Rewrite root-absolute `href`/`src`/`action` in rendered markup so an app that
57
+ * writes `<a href="/masuk">` keeps working under a base with no code change.
58
+ *
59
+ * Only ever called on the SSR'd body and head, never on the JSON data islands —
60
+ * those carry loader output, and a blind rewrite there would corrupt any string
61
+ * that merely looked like a path.
62
+ *
63
+ * Does not touch `srcset` — comma-separated candidates with descriptors, and
64
+ * nothing in the framework emits one root-absolute. An app that does needs
65
+ * `base` from "bosia".
66
+ */
67
+ export function rebaseHtmlAttrs(base: string, html: string): string {
68
+ if (!base) return html;
69
+ return rebaseCssUrls(
70
+ base,
71
+ html.replace(
72
+ ROOT_ABSOLUTE_ATTR,
73
+ (_match, attr: string, quote: string, path: string) =>
74
+ `${attr}=${quote}${withBase(base, path)}${quote}`,
75
+ ),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * The same `url(/…)` rewrite for a standalone stylesheet.
81
+ *
82
+ * A `@font-face` src or a `mask-image` inside a compiled .css file is out of
83
+ * reach of any markup rewrite — the browser resolves it against the origin, and
84
+ * a miss is silent: the wrong font renders, an icon is simply blank. Applied at
85
+ * build time, which is why a build and the server that runs it must agree on
86
+ * BASE_PATH.
87
+ */
88
+ export function rebaseCssUrls(base: string, css: string): string {
89
+ if (!base) return css;
90
+ return css.replace(
91
+ CSS_URL_ROOT,
92
+ (_match, open: string, path: string) => `${open}${withBase(base, path)}`,
93
+ );
94
+ }
@@ -0,0 +1,19 @@
1
+ import { normalizeBase } from "../basePath.ts";
2
+
3
+ /**
4
+ * The prefix this app is mounted under, handed over by the inline script
5
+ * `buildHtml` emits. `""` for a root-mounted app.
6
+ *
7
+ * The navigation path does not use this. `clientRoutes` are generated with the
8
+ * prefix already in them, so an anchor's href, the address bar and the route
9
+ * table are all the same strings — a click pushes exactly the URL that was in
10
+ * the link, and nothing rewrites a path after the user acts on it.
11
+ *
12
+ * It is needed only to build the `/__bosia/data` endpoint URL, where the mount
13
+ * prefix sits in front of the endpoint rather than in front of the route.
14
+ */
15
+ export const base: string = normalizeBase(
16
+ typeof window !== "undefined"
17
+ ? (window as unknown as { __BOSIA_BASE__?: string }).__BOSIA_BASE__
18
+ : "",
19
+ );
@@ -6,6 +6,7 @@ import { findMatch } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
  import { appState } from "./appState.svelte.ts";
8
8
  import { liveContext, shouldRerun } from "./loaderCache.ts";
9
+ import { base } from "./base.ts";
9
10
 
10
11
  /**
11
12
  * Build the `_invalidated` mask bits for a target path using the current
@@ -77,7 +78,12 @@ export function dataUrl(path: string, invalidatedBits?: string): string {
77
78
  const sep = qs ? "&" : "?";
78
79
  qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
79
80
  }
80
- return `/__bosia/data${p || "/index"}.json${qs}`;
81
+ // The one place a path is still taken apart, and it is URL construction rather
82
+ // than navigation: the data endpoint is `<base>/__bosia/data` + the *app* path,
83
+ // so the mount prefix moves from the front of the route to the front of the
84
+ // endpoint. Nothing the user sees passes through here.
85
+ if (base && (p === base || p.startsWith(`${base}/`))) p = p.slice(base.length);
86
+ return `${base}/__bosia/data${p || "/index"}.json${qs}`;
81
87
  }
82
88
 
83
89
  export const prefetchCache = new Map<string, { data: any; ts: number }>();
@@ -6,6 +6,11 @@ import { findMatch, canonicalPathname } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
  import { fireBeforeNavigate, type Navigation } from "./navListeners.ts";
8
8
 
9
+ // Everything here is a real browser path, base and all. Under a BASE_PATH mount
10
+ // the generated `clientRoutes` carry the prefix too, so an anchor's href, the
11
+ // address bar and the route table are all already in the same space — a click
12
+ // pushes exactly the URL that was in the link, untouched.
13
+
9
14
  export type NavType = "link" | "goto" | "popstate" | "form" | "enter";
10
15
 
11
16
  function buildTarget(path: string): { url: URL; params: Record<string, string> } | null {
@@ -1,4 +1,5 @@
1
1
  import type { Cookies, CookieOptions } from "./hooks.ts";
2
+ import { currentBase } from "./appBase.ts";
2
3
 
3
4
  // ─── Cookie Validation (RFC 6265) ────────────────────────
4
5
  /** Rejects characters that could inject into Set-Cookie headers. */
@@ -25,7 +26,9 @@ const VALID_COOKIE_NAME = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
25
26
  // ─── Cookie Defaults ─────────────────────────────────────
26
27
  /** Secure defaults matching SvelteKit conventions. */
27
28
  const COOKIE_DEFAULTS: CookieOptions = {
28
- path: "/",
29
+ // `path` is deliberately absent: it is resolved per-jar in the constructor,
30
+ // because the base has to be read after the env is in place rather than
31
+ // whenever this module happens to be imported.
29
32
  httpOnly: true,
30
33
  secure: true,
31
34
  sameSite: "Lax",
@@ -69,7 +72,11 @@ export class CookieJar implements Cookies {
69
72
  this._isHttps = isHttps;
70
73
  // Browsers drop Secure cookies sent over HTTP — only default `secure` on
71
74
  // when the current request actually arrived over HTTPS.
72
- this._defaults = isHttps ? COOKIE_DEFAULTS : { ...COOKIE_DEFAULTS, secure: false };
75
+ // Scoped to the mount, not the origin. Under BASE_PATH this is what keeps a
76
+ // session cookie out of the sibling apps sharing the host — and stops two
77
+ // bosia apps on one origin from overwriting each other's `session`.
78
+ const path = currentBase() || "/";
79
+ this._defaults = { ...COOKIE_DEFAULTS, path, secure: isHttps };
73
80
  }
74
81
 
75
82
  private get _incoming(): Record<string, string> {
package/src/core/env.ts CHANGED
@@ -6,6 +6,7 @@ import { join } from "path";
6
6
  const FRAMEWORK_VARS = new Set([
7
7
  "PORT",
8
8
  "NODE_ENV",
9
+ "BASE_PATH",
9
10
  "BODY_SIZE_LIMIT",
10
11
  "CSRF_ALLOWED_ORIGINS",
11
12
  "CSRF_EXEMPT_PATHS",
@@ -1,6 +1,9 @@
1
1
  // ─── Error / Redirect Helpers ────────────────────────────
2
2
  // Throw these from load() functions; the server catches and handles them.
3
3
 
4
+ import { withBase } from "./basePath.ts";
5
+ import { currentBase } from "./appBase.ts";
6
+
4
7
  export class HttpError extends Error {
5
8
  constructor(
6
9
  public status: number,
@@ -23,6 +26,12 @@ export class Redirect {
23
26
  options?: RedirectOptions,
24
27
  ) {
25
28
  validateRedirectLocation(location, options);
29
+ // Validate first, then rebase: the checks above are about what the app
30
+ // asked for, and a base prefix must never turn a rejected target into an
31
+ // accepted one. Every redirect() in every app funnels through here, which
32
+ // is the only reason mounting under a base needs no app change — an app
33
+ // writing redirect(303, "/masuk") gets /sso/masuk on the wire.
34
+ this.location = withBase(currentBase(), location);
26
35
  }
27
36
  }
28
37
 
package/src/core/html.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { getDeclaredEnvKeys } from "./env.ts";
3
3
  import { nonceAttr } from "./csp.ts";
4
- import { OUT_DIR } from "./paths.ts";
4
+ import { BASE_PATH, OUT_DIR } from "./paths.ts";
5
+ import { rebaseHtmlAttrs } from "./basePath.ts";
5
6
  import type { AppHtmlSegments } from "./appHtml.ts";
6
7
  import { interpolateSegment } from "./appHtml.ts";
7
8
 
@@ -19,12 +20,31 @@ export const distManifest: { js: string[]; css: string[]; entry: string; tw?: st
19
20
  export const isDev = process.env.NODE_ENV !== "production";
20
21
  const cacheBust = isDev ? `?v=${Date.now()}` : "";
21
22
 
23
+ // Every URL the framework itself emits into the document, prefixed once here so
24
+ // mounting under a BASE_PATH is not thirteen separate string edits. All four are
25
+ // "" + the original path when no base is set.
26
+ const DIST = `${BASE_PATH}/dist/client`;
27
+ const TW_CSS = `${BASE_PATH}/bosia-tw.css`;
28
+ const FAVICON = `${BASE_PATH}/favicon.svg`;
29
+ const SSE = `${BASE_PATH}/__bosia/sse`;
30
+
31
+ /**
32
+ * Handed to the client bundle so its router strips the same prefix the server
33
+ * added. Emitted before the module script, and omitted entirely at the origin
34
+ * root so a root-mounted app carries no extra bytes.
35
+ */
36
+ export function baseScript(nonce?: string): string {
37
+ return BASE_PATH
38
+ ? `\n <script${nonceAttr(nonce)}>window.__BOSIA_BASE__=${JSON.stringify(BASE_PATH)};</script>`
39
+ : "";
40
+ }
41
+
22
42
  /** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
23
43
  * hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
24
44
  function twCssLink(): string {
25
45
  return distManifest.tw
26
- ? `<link rel="stylesheet" href="/dist/client/${distManifest.tw}">`
27
- : `<link rel="stylesheet" href="/bosia-tw.css${cacheBust}">`;
46
+ ? `<link rel="stylesheet" href="${DIST}/${distManifest.tw}">`
47
+ : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
28
48
  }
29
49
 
30
50
  /** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
@@ -116,8 +136,15 @@ export function buildHtml(
116
136
  bodyEndExtras?: string[],
117
137
  segments?: AppHtmlSegments,
118
138
  ): string {
139
+ // An app writes <a href="/masuk">; under a base the browser has to be handed
140
+ // /sso/masuk or it walks off this app entirely. Only the rendered markup is
141
+ // touched — never the JSON data islands below, whose strings are loader
142
+ // output and would be corrupted by a path rewrite.
143
+ body = rebaseHtmlAttrs(BASE_PATH, body);
144
+ head = rebaseHtmlAttrs(BASE_PATH, head);
145
+
119
146
  const cssLinks = (distManifest.css ?? [])
120
- .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
147
+ .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
121
148
  .join("\n ");
122
149
 
123
150
  const fallbackTitle = head.includes("<title>") ? "" : "<title>Bosia App</title>";
@@ -147,9 +174,9 @@ export function buildHtml(
147
174
  : "";
148
175
 
149
176
  const scripts = csr
150
- ? `${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
177
+ ? `${baseScript(nonce)}${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="${DIST}/${distManifest.entry}${cacheBust}"></script>`
151
178
  : isDev
152
- ? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
179
+ ? `\n <script${n}>!function r(){var e=new EventSource("${SSE}");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
153
180
  : "";
154
181
 
155
182
  const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
@@ -164,7 +191,7 @@ export function buildHtml(
164
191
  const tailInterpolated = interpolateSegment(segments.tail, { nonce });
165
192
  const faviconLine = segments.hasCustomFavicon
166
193
  ? ""
167
- : ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n`;
194
+ : ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
168
195
 
169
196
  return (
170
197
  headOpenInterpolated +
@@ -185,7 +212,7 @@ export function buildHtml(
185
212
  <meta charset="UTF-8">
186
213
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
187
214
  ${fallbackTitle}
188
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
215
+ <link rel="icon" type="image/svg+xml" href="${FAVICON}">
189
216
  ${head}
190
217
  ${cssLinks}
191
218
  ${twCssLink()}
@@ -210,20 +237,20 @@ export function buildHtmlShellOpen(
210
237
  const key = safeLang(lang);
211
238
  const n = nonceAttr(nonce);
212
239
  const cssLinks = (distManifest.css ?? [])
213
- .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
240
+ .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
214
241
  .join("\n ");
215
242
 
216
243
  if (segments) {
217
244
  const headOpenInterpolated = interpolateSegment(segments.headOpen, { lang: key, nonce });
218
245
  const faviconLine = segments.hasCustomFavicon
219
246
  ? ""
220
- : ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n`;
247
+ : ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
221
248
  return (
222
249
  headOpenInterpolated +
223
250
  `\n ${faviconLine}${cssLinks}\n` +
224
251
  ` ${twCssLink()}\n` +
225
252
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
226
- ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
253
+ ` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
227
254
  );
228
255
  }
229
256
 
@@ -231,11 +258,11 @@ export function buildHtmlShellOpen(
231
258
  `<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
232
259
  ` <meta charset="UTF-8">\n` +
233
260
  ` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
234
- ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
261
+ ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n` +
235
262
  ` ${cssLinks}\n` +
236
263
  ` ${twCssLink()}\n` +
237
264
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
238
- ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
265
+ ` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
239
266
  );
240
267
  }
241
268
 
@@ -290,7 +317,9 @@ export function buildMetadataChunk(
290
317
  out += `</head>\n<body>\n${SPINNER}`;
291
318
  }
292
319
 
293
- return out;
320
+ // All markup, no data islands — safe to rebase wholesale, which is what picks
321
+ // up an app's own headExtras (a canonical link, an og:image on a local file).
322
+ return rebaseHtmlAttrs(BASE_PATH, out);
294
323
  }
295
324
 
296
325
  export function escapeHtml(s: string): string {
@@ -319,12 +348,17 @@ export function buildHtmlTail(
319
348
  layoutDeps: any[] | null = null,
320
349
  segments?: AppHtmlSegments,
321
350
  ): string {
351
+ // Same rebase as buildHtml — the streamed tail carries the identical markup.
352
+ body = rebaseHtmlAttrs(BASE_PATH, body);
353
+ head = rebaseHtmlAttrs(BASE_PATH, head);
354
+
322
355
  const n = nonceAttr(nonce);
323
356
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
324
357
  out += `\n<div id="app">${body}</div>`;
325
358
  if (head)
326
359
  out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
327
360
  if (csr) {
361
+ out += baseScript(nonce);
328
362
  const publicEnv = getPublicDynamicEnv();
329
363
  if (Object.keys(publicEnv).length > 0) {
330
364
  out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
@@ -342,9 +376,9 @@ export function buildHtmlTail(
342
376
  if (ssrFlag || depsInject) {
343
377
  out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
344
378
  }
345
- out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
379
+ out += `\n<script${n} type="module" src="${DIST}/${distManifest.entry}${cacheBust}"></script>`;
346
380
  } else if (isDev) {
347
- out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
381
+ out += `\n<script${n}>!function r(){var e=new EventSource("${SSE}");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
348
382
  }
349
383
  if (bodyEndExtras?.length) {
350
384
  for (const fragment of bodyEndExtras) {
package/src/core/paths.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join, dirname } from "path";
2
2
  import { existsSync } from "fs";
3
+ import { normalizeBase } from "./basePath.ts";
3
4
 
4
5
  // This file lives at src/core/paths.ts → package root is ../..
5
6
  const BOSIA_PKG_DIR = join(import.meta.dir, "..", "..");
@@ -33,6 +34,15 @@ export const BOSIA_NODE_PATH = ALL_NM.join(":");
33
34
  // `bun run build` (./dist) don't clobber each other.
34
35
  export const OUT_DIR = process.env.BOSIA_OUT_DIR ?? "./dist";
35
36
 
37
+ /**
38
+ * URL prefix the whole app is mounted under — `""` (origin root) unless
39
+ * `BASE_PATH` says otherwise. Read once here so every consumer agrees; the
40
+ * browser half gets the same value through `window.__BOSIA_BASE__`.
41
+ *
42
+ * Server-only: this module reaches for `fs`, so it never enters a client bundle.
43
+ */
44
+ export const BASE_PATH = normalizeBase(process.env.BASE_PATH);
45
+
36
46
  /** Find a binary from bosia's dependencies (handles hoisting) */
37
47
  export function resolveBosiaBin(name: string): string {
38
48
  for (const nm of ALL_NM) {
@@ -3,7 +3,7 @@ import { createServer } from "net";
3
3
  import { join } from "path";
4
4
  import type { RouteManifest, TrailingSlash } from "./types.ts";
5
5
 
6
- import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
6
+ import { BASE_PATH, BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
7
7
 
8
8
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
9
9
  export function getEphemeralPort(): Promise<number> {
@@ -184,7 +184,10 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
184
184
  try {
185
185
  // Poll /_health until ready (max 10s). Check first, sleep only on failure —
186
186
  // avoids a guaranteed floor when the server is already up.
187
- const base = `http://localhost:${port}`;
187
+ // The child inherits BASE_PATH, so it 404s anything outside the mount —
188
+ // including /_health. Prefix here and every fetch below follows; the files
189
+ // we write stay app-space, which is what the server looks them up by.
190
+ const base = `http://localhost:${port}${BASE_PATH}`;
188
191
  let ready = false;
189
192
  const deadline = Date.now() + 10_000;
190
193
  while (Date.now() < deadline) {
@@ -1,5 +1,25 @@
1
1
  import { writeFileSync, mkdirSync } from "fs";
2
2
  import type { RouteManifest } from "./types.ts";
3
+ import { currentBase } from "./appBase.ts";
4
+
5
+ /**
6
+ * The pattern the *client* router matches against. It sees real browser URLs, so
7
+ * under a BASE_PATH mount the table has to carry the prefix — that is what lets
8
+ * `findMatch` take `location.pathname` and an anchor's href exactly as they are,
9
+ * with no conversion anywhere in the navigation path.
10
+ *
11
+ * `serverRoutes` deliberately does not get this: the server strips the prefix at
12
+ * the edge of the request, so hooks, `load()` and `event.url.pathname` keep
13
+ * seeing app-space paths and app code never has to know it is mounted.
14
+ *
15
+ * The root route maps to the bare base (`/sso`, not `/sso/`) so it agrees with
16
+ * the URL a browser actually lands on.
17
+ */
18
+ function clientPattern(pattern: string): string {
19
+ const base = currentBase();
20
+ if (!base) return pattern;
21
+ return pattern === "/" ? base : base + pattern;
22
+ }
3
23
 
4
24
  // ─── Route File Generator ─────────────────────────────────
5
25
  // Generates .bosia/routes.ts — ONE file with three exports:
@@ -69,7 +89,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
69
89
  .map((id) => (id === null ? "null" : JSON.stringify(id)))
70
90
  .join(", ");
71
91
  lines.push(" {");
72
- lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
92
+ lines.push(` pattern: ${JSON.stringify(clientPattern(r.pattern))},`);
73
93
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
74
94
  lines.push(` layouts: [${layoutImports}],`);
75
95
  lines.push(` errorPages: [${errorPageImports}],`);
@@ -202,7 +222,7 @@ function generateClientRoutesFile(
202
222
  .map((id) => (id === null ? "null" : JSON.stringify(id)))
203
223
  .join(", ");
204
224
  lines.push(" {");
205
- lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
225
+ lines.push(` pattern: ${JSON.stringify(clientPattern(r.pattern))},`);
206
226
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
207
227
  lines.push(` layouts: [${layoutImports}],`);
208
228
  lines.push(` errorPages: [${errorPageImports}],`);
@@ -23,7 +23,8 @@ import type { CorsConfig } from "./cors.ts";
23
23
  import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
24
24
  import { isDev, compress, isStaticPath } from "./html.ts";
25
25
  import { dev500WithPlugins } from "./dev-500.ts";
26
- import { OUT_DIR } from "./paths.ts";
26
+ import { BASE_PATH, OUT_DIR } from "./paths.ts";
27
+ import { stripBase, withBase } from "./basePath.ts";
27
28
  import { pidsOnPort } from "./port.ts";
28
29
  import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
29
30
  import { dedup } from "./dedup.ts";
@@ -158,6 +159,10 @@ if (CSP_DIRECTIVES_TEMPLATE) {
158
159
  console.log(`🔒 CSP: opt-in header active`);
159
160
  }
160
161
 
162
+ if (BASE_PATH) {
163
+ console.log(`📍 Mounted under ${BASE_PATH} (BASE_PATH)`);
164
+ }
165
+
161
166
  // ─── Core Request Resolver ────────────────────────────────
162
167
  // This is the inner handler that hooks wrap around.
163
168
 
@@ -632,7 +637,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
632
637
  if (canonical !== null) {
633
638
  return new Response(null, {
634
639
  status: 308,
635
- headers: { Location: canonical + url.search + url.hash },
640
+ // `path` is app-space — the base came off at the top of handleRequest.
641
+ // This Location goes back to the browser, so it has to be put back on,
642
+ // or a `trailingSlash: "always"` route 308s every request off the mount.
643
+ headers: { Location: withBase(BASE_PATH, canonical) + url.search + url.hash },
636
644
  });
637
645
  }
638
646
  }
@@ -864,6 +872,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
864
872
  if (fwdProto) url.protocol = `${fwdProto}:`;
865
873
  }
866
874
 
875
+ // Mounted under BASE_PATH? Everything downstream — hooks, the router, the
876
+ // /_health and /__bosia tests below — works in app space, so the prefix comes
877
+ // off exactly once, here, before anything reads a pathname. A request that is
878
+ // not under the base was never this app's to answer.
879
+ if (BASE_PATH) {
880
+ const appPath = stripBase(BASE_PATH, url.pathname);
881
+ if (appPath === null) return new Response("Not Found", { status: 404 });
882
+ url.pathname = appPath;
883
+ }
884
+
867
885
  // Reject new non-health requests during shutdown
868
886
  if (shuttingDown && url.pathname !== "/_health") {
869
887
  return new Response("Service Unavailable", {
@@ -2,6 +2,8 @@ import { compile, compileModule } from "svelte/compiler";
2
2
  import type { BunPlugin } from "bun";
3
3
 
4
4
  import { auditSvelteSource } from "./svelteAudit.ts";
5
+ import { rebaseHtmlAttrs } from "./basePath.ts";
6
+ import { currentBase } from "./appBase.ts";
5
7
  import { loadBosiaConfig } from "./config.ts";
6
8
  import type { StrictImportsOption } from "./types/plugin.ts";
7
9
 
@@ -61,6 +63,37 @@ function fixBindShadow(code: string): string {
61
63
  .replace(/\bfunction set\(\$\$value\)/g, () => "function $$s($$value)");
62
64
  }
63
65
 
66
+ // Under a BASE_PATH mount, a component's own `<a href="/masuk">` is the one URL
67
+ // the server-side HTML rewrite cannot hold on to: the client re-renders on mount
68
+ // and Svelte writes the original literal straight back into the DOM. Nothing
69
+ // downstream saves it: the router converts nothing, so the href a crawler reads,
70
+ // a middle-click opens, "copy link address" yields — and the click itself — all
71
+ // point at the origin root, i.e. at whatever other app lives there.
72
+ //
73
+ // So the prefix is baked in at compile time instead. Only the markup is touched:
74
+ // a root-absolute string inside <script> could be anything, and guessing is how
75
+ // you corrupt an unrelated constant. Attribute interpolation survives, since the
76
+ // rewrite only touches the leading literal — href="/user/{id}" is still valid.
77
+ //
78
+ // Dynamic values (href={someUrl}) are built in script and still need `base`.
79
+ export function rebaseSvelteMarkup(source: string): string {
80
+ // currentBase(), not the paths.ts const: that one freezes at import, and this
81
+ // module is loaded long before a build sets the env.
82
+ const base = currentBase();
83
+ if (!base) return source;
84
+
85
+ const scripts: string[] = [];
86
+ const masked = source.replace(/<script[\s\S]*?<\/script>/gi, (block) => {
87
+ scripts.push(block);
88
+ return `<!--bosia:script:${scripts.length - 1}-->`;
89
+ });
90
+
91
+ return rebaseHtmlAttrs(base, masked).replace(
92
+ /<!--bosia:script:(\d+)-->/g,
93
+ (_match, index: string) => scripts[Number(index)],
94
+ );
95
+ }
96
+
64
97
  export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
65
98
  const generate = target === "browser" ? "client" : "server";
66
99
  const dev = process.env.NODE_ENV !== "production";
@@ -75,7 +108,7 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
75
108
 
76
109
  build.onLoad({ filter: /\.svelte$/ }, async (args) => {
77
110
  const source = await Bun.file(args.path).text();
78
- const result = compile(source, {
111
+ const result = compile(rebaseSvelteMarkup(source), {
79
112
  generate,
80
113
  css: target === "browser" ? "injected" : "external",
81
114
  dev,
@@ -1,5 +1,7 @@
1
- import { readFileSync, renameSync } from "fs";
1
+ import { readFileSync, renameSync, writeFileSync } from "fs";
2
2
  import { join, dirname } from "path";
3
+ import { rebaseCssUrls } from "./basePath.ts";
4
+ import { BASE_PATH } from "./paths.ts";
3
5
 
4
6
  /** Temp filename Tailwind CLI writes to before the content-hash rename. */
5
7
  export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
@@ -10,6 +12,15 @@ export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
10
12
  * so it gets immutable caching). Returns the final basename.
11
13
  */
12
14
  export function finalizeTailwindCss(tempPath: string): string {
15
+ // Rebase before hashing, so the hash describes the bytes actually served. A
16
+ // @font-face src or mask-image written as url(/fonts/…) resolves against the
17
+ // origin and would land outside the mount — silently, as a fallback font or a
18
+ // blank icon rather than an error.
19
+ if (BASE_PATH) {
20
+ const rebased = rebaseCssUrls(BASE_PATH, readFileSync(tempPath, "utf-8"));
21
+ writeFileSync(tempPath, rebased);
22
+ }
23
+
13
24
  const bytes = readFileSync(tempPath);
14
25
  const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
15
26
  const name = `bosia-tw-${hash}.css`;
package/src/lib/index.ts CHANGED
@@ -8,6 +8,14 @@
8
8
  export { cn, getServerTime } from "./utils.ts";
9
9
  export { sequence, NO_FRAME_GUARD_HEADER } from "../core/hooks.ts";
10
10
  export { error, redirect, fail } from "../core/errors.ts";
11
+ // `base` is the BASE_PATH the app is mounted under, "" at the origin root.
12
+ // Almost nothing needs it: `redirect()` and every href in rendered markup are
13
+ // rebased for you. It exists for the one case the framework cannot see — an
14
+ // absolute URL an app builds by hand, e.g. `${url.origin}${base}/atur-sandi`
15
+ // for a link that will be pasted somewhere else. `event.url.pathname` is app
16
+ // space by the time a load() or action sees it, so never prepend it there.
17
+ import { currentBase } from "../core/appBase.ts";
18
+ export const base: string = currentBase();
11
19
  // `invalidate` / `invalidateAll` (server response-cache eviction) live in
12
20
  // "bosia/server" — they touch server-process state and pulling them into
13
21
  // the shared barrel leaks `process.env` reads into client bundles.