bosia 0.9.0 → 0.9.1

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.9.0",
3
+ "version": "0.9.1",
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": [
@@ -52,27 +52,61 @@ const ROOT_ABSOLUTE_ATTR = /\b(href|src|action|formaction)=("|')(\/(?!\/)[^"']*)
52
52
  // error, so this one is easy to miss.
53
53
  const CSS_URL_ROOT = /(url\(\s*(?:"|'|'|["'])?)(\/(?!\/)[^)"'&\s]*)/gi;
54
54
 
55
+ // Responsive image candidates. Each is "url" plus an optional `2x` / `640w`
56
+ // descriptor, comma-separated, so the value needs splitting before `withBase`
57
+ // can see a path.
58
+ const SRCSET_ATTR = /\b(srcset|imagesrcset)=("|')([^"']*)\2/gi;
59
+
60
+ // Leading whitespace + the url token of one candidate; the descriptor that may
61
+ // follow is left exactly as written.
62
+ const SRCSET_CANDIDATE = /^(\s*)(\S+)/;
63
+
64
+ /**
65
+ * Rebase every candidate url in one `srcset` value.
66
+ *
67
+ * A `data:` URI can contain commas of its own (base64 padding aside, any
68
+ * `text/plain,a,b` does), and splitting on those would shred it. Nothing
69
+ * root-absolute can live inside a data URI anyway, so the whole value is left
70
+ * alone when one appears — split on top-level commas only if an app ever mixes
71
+ * a data URI with a root-absolute candidate in the same attribute.
72
+ */
73
+ function rebaseSrcset(base: string, value: string): string {
74
+ if (value.includes("data:")) return value;
75
+ return value
76
+ .split(",")
77
+ .map((candidate) =>
78
+ candidate.replace(
79
+ SRCSET_CANDIDATE,
80
+ (_m, space: string, url: string) => space + withBase(base, url),
81
+ ),
82
+ )
83
+ .join(",");
84
+ }
85
+
55
86
  /**
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.
87
+ * Rewrite root-absolute `href`/`src`/`action`/`srcset` in rendered markup so an
88
+ * app that writes `<a href="/masuk">` keeps working under a base with no code
89
+ * change.
58
90
  *
59
91
  * Only ever called on the SSR'd body and head, never on the JSON data islands —
60
92
  * those carry loader output, and a blind rewrite there would corrupt any string
61
93
  * 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
94
  */
67
95
  export function rebaseHtmlAttrs(base: string, html: string): string {
68
96
  if (!base) return html;
69
97
  return rebaseCssUrls(
70
98
  base,
71
- html.replace(
72
- ROOT_ABSOLUTE_ATTR,
73
- (_match, attr: string, quote: string, path: string) =>
74
- `${attr}=${quote}${withBase(base, path)}${quote}`,
75
- ),
99
+ html
100
+ .replace(
101
+ ROOT_ABSOLUTE_ATTR,
102
+ (_match, attr: string, quote: string, path: string) =>
103
+ `${attr}=${quote}${withBase(base, path)}${quote}`,
104
+ )
105
+ .replace(
106
+ SRCSET_ATTR,
107
+ (_match, attr: string, quote: string, value: string) =>
108
+ `${attr}=${quote}${rebaseSrcset(base, value)}${quote}`,
109
+ ),
76
110
  );
77
111
  }
78
112
 
package/src/core/build.ts CHANGED
@@ -11,6 +11,7 @@ import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
11
11
  import { loadEnv, classifyEnvVars } from "./env.ts";
12
12
  import { generateEnvModules } from "./envCodegen.ts";
13
13
  import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin } from "./paths.ts";
14
+ import { currentBase } from "./appBase.ts";
14
15
  import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
15
16
  import { loadPlugins } from "./config.ts";
16
17
  import type { BuildContext } from "./types/plugin.ts";
@@ -276,6 +277,9 @@ const distManifest = {
276
277
  "hydrate.js",
277
278
  serverEntry,
278
279
  tw: twFile,
280
+ // The CSS urls and the client route table are baked in with this prefix.
281
+ // Stamped so the server can warn when it boots with a different one.
282
+ basePath: currentBase(),
279
283
  };
280
284
  writeFileSync(`${OUT_DIR}/manifest.json`, JSON.stringify(distManifest, null, 2));
281
285
  console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
@@ -1,4 +1,4 @@
1
- import { normalizeBase } from "../basePath.ts";
1
+ import { currentBase } from "../appBase.ts";
2
2
 
3
3
  /**
4
4
  * The prefix this app is mounted under, handed over by the inline script
@@ -9,11 +9,7 @@ import { normalizeBase } from "../basePath.ts";
9
9
  * table are all the same strings — a click pushes exactly the URL that was in
10
10
  * the link, and nothing rewrites a path after the user acts on it.
11
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.
12
+ * It is needed only to build the `/__bosia/data` and `/__bosia/sse` URLs, where
13
+ * the mount prefix sits in front of the endpoint rather than in front of the route.
14
14
  */
15
- export const base: string = normalizeBase(
16
- typeof window !== "undefined"
17
- ? (window as unknown as { __BOSIA_BASE__?: string }).__BOSIA_BASE__
18
- : "",
19
- );
15
+ export const base: string = currentBase();
@@ -7,6 +7,7 @@ import { clientRoutes } from "bosia:routes";
7
7
  import { appState } from "./appState.svelte.ts";
8
8
  import { captureSnapshot, liveContext, type CacheEntry } from "./loaderCache.ts";
9
9
  import type { LoaderDeps } from "../hooks.ts";
10
+ import { base } from "./base.ts";
10
11
 
11
12
  // Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
12
13
  compileRoutes(clientRoutes);
@@ -182,7 +183,7 @@ if (process.env.NODE_ENV !== "production") {
182
183
  let retryDelay = 1000;
183
184
 
184
185
  function connectSSE() {
185
- const es = new EventSource("/__bosia/sse");
186
+ const es = new EventSource(`${base}/__bosia/sse`);
186
187
 
187
188
  es.addEventListener("reload", () => {
188
189
  console.log("[Bosia] Reloading...");
@@ -5,6 +5,8 @@
5
5
  import { findMatch, canonicalPathname } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
  import { fireBeforeNavigate, type Navigation } from "./navListeners.ts";
8
+ import { base } from "./base.ts";
9
+ import { withBase } from "../basePath.ts";
8
10
 
9
11
  // Everything here is a real browser path, base and all. Under a BASE_PATH mount
10
12
  // the generated `clientRoutes` carry the prefix too, so an anchor's href, the
@@ -98,6 +100,19 @@ export const router = new (class Router {
98
100
  const pathname = path.split("?")[0].split("#")[0];
99
101
  const match = findMatch(clientRoutes, pathname);
100
102
  if (!match) {
103
+ // Every nav — goto, link, form — falls through here before leaving the
104
+ // app. Under a mount, a path that would have matched *with* the prefix is
105
+ // an app-space path handed to goto(); warn instead of rewriting, since the
106
+ // shipped design is that the router converts nothing.
107
+ if (
108
+ process.env.NODE_ENV !== "production" &&
109
+ base &&
110
+ findMatch(clientRoutes, withBase(base, pathname))
111
+ ) {
112
+ console.warn(
113
+ `[bosia] "${path}" matched no route and is leaving the app — it is mounted at "${base}". Use "${withBase(base, path)}".`,
114
+ );
115
+ }
101
116
  window.location.href = path;
102
117
  return;
103
118
  }
package/src/core/html.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { getDeclaredEnvKeys } from "./env.ts";
3
3
  import { nonceAttr } from "./csp.ts";
4
- import { BASE_PATH, OUT_DIR } from "./paths.ts";
4
+ import { OUT_DIR } from "./paths.ts";
5
5
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
+ import { currentBase } from "./appBase.ts";
6
7
  import type { AppHtmlSegments } from "./appHtml.ts";
7
8
  import { interpolateSegment } from "./appHtml.ts";
8
9
 
@@ -10,7 +11,13 @@ import { interpolateSegment } from "./appHtml.ts";
10
11
  // Maps hashed filenames → script/link tags.
11
12
  // Cached at startup; server restarts on rebuild in dev anyway.
12
13
 
13
- export const distManifest: { js: string[]; css: string[]; entry: string; tw?: string } = (() => {
14
+ export const distManifest: {
15
+ js: string[];
16
+ css: string[];
17
+ entry: string;
18
+ tw?: string;
19
+ basePath?: string;
20
+ } = (() => {
14
21
  const p = `${OUT_DIR}/manifest.json`;
15
22
  return existsSync(p)
16
23
  ? JSON.parse(readFileSync(p, "utf-8"))
@@ -23,10 +30,11 @@ const cacheBust = isDev ? `?v=${Date.now()}` : "";
23
30
  // Every URL the framework itself emits into the document, prefixed once here so
24
31
  // mounting under a BASE_PATH is not thirteen separate string edits. All four are
25
32
  // "" + 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`;
33
+ const B = currentBase();
34
+ const DIST = `${B}/dist/client`;
35
+ const TW_CSS = `${B}/bosia-tw.css`;
36
+ const FAVICON = `${B}/favicon.svg`;
37
+ const SSE = `${B}/__bosia/sse`;
30
38
 
31
39
  /**
32
40
  * Handed to the client bundle so its router strips the same prefix the server
@@ -34,8 +42,8 @@ const SSE = `${BASE_PATH}/__bosia/sse`;
34
42
  * root so a root-mounted app carries no extra bytes.
35
43
  */
36
44
  export function baseScript(nonce?: string): string {
37
- return BASE_PATH
38
- ? `\n <script${nonceAttr(nonce)}>window.__BOSIA_BASE__=${JSON.stringify(BASE_PATH)};</script>`
45
+ return B
46
+ ? `\n <script${nonceAttr(nonce)}>window.__BOSIA_BASE__=${JSON.stringify(B)};</script>`
39
47
  : "";
40
48
  }
41
49
 
@@ -140,8 +148,8 @@ export function buildHtml(
140
148
  // /sso/masuk or it walks off this app entirely. Only the rendered markup is
141
149
  // touched — never the JSON data islands below, whose strings are loader
142
150
  // output and would be corrupted by a path rewrite.
143
- body = rebaseHtmlAttrs(BASE_PATH, body);
144
- head = rebaseHtmlAttrs(BASE_PATH, head);
151
+ body = rebaseHtmlAttrs(B, body);
152
+ head = rebaseHtmlAttrs(B, head);
145
153
 
146
154
  const cssLinks = (distManifest.css ?? [])
147
155
  .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
@@ -319,7 +327,7 @@ export function buildMetadataChunk(
319
327
 
320
328
  // All markup, no data islands — safe to rebase wholesale, which is what picks
321
329
  // up an app's own headExtras (a canonical link, an og:image on a local file).
322
- return rebaseHtmlAttrs(BASE_PATH, out);
330
+ return rebaseHtmlAttrs(B, out);
323
331
  }
324
332
 
325
333
  export function escapeHtml(s: string): string {
@@ -349,8 +357,8 @@ export function buildHtmlTail(
349
357
  segments?: AppHtmlSegments,
350
358
  ): string {
351
359
  // Same rebase as buildHtml — the streamed tail carries the identical markup.
352
- body = rebaseHtmlAttrs(BASE_PATH, body);
353
- head = rebaseHtmlAttrs(BASE_PATH, head);
360
+ body = rebaseHtmlAttrs(B, body);
361
+ head = rebaseHtmlAttrs(B, head);
354
362
 
355
363
  const n = nonceAttr(nonce);
356
364
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
package/src/core/paths.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { join, dirname } from "path";
2
2
  import { existsSync } from "fs";
3
- import { normalizeBase } from "./basePath.ts";
4
3
 
5
4
  // This file lives at src/core/paths.ts → package root is ../..
6
5
  const BOSIA_PKG_DIR = join(import.meta.dir, "..", "..");
@@ -34,15 +33,6 @@ export const BOSIA_NODE_PATH = ALL_NM.join(":");
34
33
  // `bun run build` (./dist) don't clobber each other.
35
34
  export const OUT_DIR = process.env.BOSIA_OUT_DIR ?? "./dist";
36
35
 
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
-
46
36
  /** Find a binary from bosia's dependencies (handles hoisting) */
47
37
  export function resolveBosiaBin(name: string): string {
48
38
  for (const nm of ALL_NM) {
@@ -3,7 +3,8 @@ import { createServer } from "net";
3
3
  import { join } from "path";
4
4
  import type { RouteManifest, TrailingSlash } from "./types.ts";
5
5
 
6
- import { BASE_PATH, BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
6
+ import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
7
+ import { currentBase } from "./appBase.ts";
7
8
 
8
9
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
9
10
  export function getEphemeralPort(): Promise<number> {
@@ -187,7 +188,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
187
188
  // The child inherits BASE_PATH, so it 404s anything outside the mount —
188
189
  // including /_health. Prefix here and every fetch below follows; the files
189
190
  // we write stay app-space, which is what the server looks them up by.
190
- const base = `http://localhost:${port}${BASE_PATH}`;
191
+ const base = `http://localhost:${port}${currentBase()}`;
191
192
  let ready = false;
192
193
  const deadline = Date.now() + 10_000;
193
194
  while (Date.now() < deadline) {
@@ -21,10 +21,11 @@ import type { CsrfConfig } from "./csrf.ts";
21
21
  import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
22
22
  import type { CorsConfig } from "./cors.ts";
23
23
  import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
24
- import { isDev, compress, isStaticPath } from "./html.ts";
24
+ import { isDev, compress, isStaticPath, distManifest } from "./html.ts";
25
25
  import { dev500WithPlugins } from "./dev-500.ts";
26
- import { BASE_PATH, OUT_DIR } from "./paths.ts";
26
+ import { OUT_DIR } from "./paths.ts";
27
27
  import { stripBase, withBase } from "./basePath.ts";
28
+ import { currentBase } from "./appBase.ts";
28
29
  import { pidsOnPort } from "./port.ts";
29
30
  import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
30
31
  import { dedup } from "./dedup.ts";
@@ -159,8 +160,17 @@ if (CSP_DIRECTIVES_TEMPLATE) {
159
160
  console.log(`🔒 CSP: opt-in header active`);
160
161
  }
161
162
 
162
- if (BASE_PATH) {
163
- console.log(`📍 Mounted under ${BASE_PATH} (BASE_PATH)`);
163
+ if (currentBase()) {
164
+ console.log(`📍 Mounted under ${currentBase()} (BASE_PATH)`);
165
+ }
166
+
167
+ // The CSS urls and the client route table are baked in at build time, so a
168
+ // build and the server running it have to agree. Older dist/ artifacts carry no
169
+ // `basePath` field — stay quiet for those rather than warn about nothing.
170
+ if (distManifest.basePath !== undefined && distManifest.basePath !== currentBase()) {
171
+ console.warn(
172
+ `⚠️ Built for BASE_PATH="${distManifest.basePath}" but running with "${currentBase()}" — CSS urls and the client route table are baked in and will not match.`,
173
+ );
164
174
  }
165
175
 
166
176
  // ─── Core Request Resolver ────────────────────────────────
@@ -640,7 +650,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
640
650
  // `path` is app-space — the base came off at the top of handleRequest.
641
651
  // This Location goes back to the browser, so it has to be put back on,
642
652
  // or a `trailingSlash: "always"` route 308s every request off the mount.
643
- headers: { Location: withBase(BASE_PATH, canonical) + url.search + url.hash },
653
+ headers: { Location: withBase(currentBase(), canonical) + url.search + url.hash },
644
654
  });
645
655
  }
646
656
  }
@@ -876,8 +886,9 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
876
886
  // /_health and /__bosia tests below — works in app space, so the prefix comes
877
887
  // off exactly once, here, before anything reads a pathname. A request that is
878
888
  // not under the base was never this app's to answer.
879
- if (BASE_PATH) {
880
- const appPath = stripBase(BASE_PATH, url.pathname);
889
+ const base = currentBase();
890
+ if (base) {
891
+ const appPath = stripBase(base, url.pathname);
881
892
  if (appPath === null) return new Response("Not Found", { status: 404 });
882
893
  url.pathname = appPath;
883
894
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, renameSync, writeFileSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
  import { rebaseCssUrls } from "./basePath.ts";
4
- import { BASE_PATH } from "./paths.ts";
4
+ import { currentBase } from "./appBase.ts";
5
5
 
6
6
  /** Temp filename Tailwind CLI writes to before the content-hash rename. */
7
7
  export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
@@ -16,8 +16,13 @@ export function finalizeTailwindCss(tempPath: string): string {
16
16
  // @font-face src or mask-image written as url(/fonts/…) resolves against the
17
17
  // origin and would land outside the mount — silently, as a fallback font or a
18
18
  // blank icon rather than an error.
19
- if (BASE_PATH) {
20
- const rebased = rebaseCssUrls(BASE_PATH, readFileSync(tempPath, "utf-8"));
19
+ //
20
+ // Read here, not at import: build.ts calls loadEnv() after its imports, so a
21
+ // BASE_PATH that lives in .env.production is not in process.env yet when this
22
+ // module loads.
23
+ const base = currentBase();
24
+ if (base) {
25
+ const rebased = rebaseCssUrls(base, readFileSync(tempPath, "utf-8"));
21
26
  writeFileSync(tempPath, rebased);
22
27
  }
23
28