bosia 0.8.16 → 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 +1 -1
- package/src/core/appBase.ts +25 -0
- package/src/core/appHtml.ts +7 -1
- package/src/core/basePath.ts +128 -0
- package/src/core/build.ts +4 -0
- package/src/core/client/base.ts +15 -0
- package/src/core/client/hydrate.ts +2 -1
- package/src/core/client/prefetch.ts +7 -1
- package/src/core/client/router.svelte.ts +20 -0
- package/src/core/cookies.ts +9 -2
- package/src/core/env.ts +1 -0
- package/src/core/errors.ts +9 -0
- package/src/core/html.ts +58 -16
- package/src/core/prerender.ts +5 -1
- package/src/core/routeFile.ts +22 -2
- package/src/core/server.ts +31 -2
- package/src/core/svelteCompiler.ts +34 -1
- package/src/core/twHash.ts +17 -1
- package/src/lib/index.ts +8 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/core/appHtml.ts
CHANGED
|
@@ -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
|
-
|
|
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,128 @@
|
|
|
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*(?:"|'|'|["'])?)(\/(?!\/)[^)"'&\s]*)/gi;
|
|
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
|
+
|
|
86
|
+
/**
|
|
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.
|
|
90
|
+
*
|
|
91
|
+
* Only ever called on the SSR'd body and head, never on the JSON data islands —
|
|
92
|
+
* those carry loader output, and a blind rewrite there would corrupt any string
|
|
93
|
+
* that merely looked like a path.
|
|
94
|
+
*/
|
|
95
|
+
export function rebaseHtmlAttrs(base: string, html: string): string {
|
|
96
|
+
if (!base) return html;
|
|
97
|
+
return rebaseCssUrls(
|
|
98
|
+
base,
|
|
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
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The same `url(/…)` rewrite for a standalone stylesheet.
|
|
115
|
+
*
|
|
116
|
+
* A `@font-face` src or a `mask-image` inside a compiled .css file is out of
|
|
117
|
+
* reach of any markup rewrite — the browser resolves it against the origin, and
|
|
118
|
+
* a miss is silent: the wrong font renders, an icon is simply blank. Applied at
|
|
119
|
+
* build time, which is why a build and the server that runs it must agree on
|
|
120
|
+
* BASE_PATH.
|
|
121
|
+
*/
|
|
122
|
+
export function rebaseCssUrls(base: string, css: string): string {
|
|
123
|
+
if (!base) return css;
|
|
124
|
+
return css.replace(
|
|
125
|
+
CSS_URL_ROOT,
|
|
126
|
+
(_match, open: string, path: string) => `${open}${withBase(base, path)}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
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(", ")}`);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { currentBase } from "../appBase.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` and `/__bosia/sse` URLs, where
|
|
13
|
+
* the mount prefix sits in front of the endpoint rather than in front of the route.
|
|
14
|
+
*/
|
|
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(
|
|
186
|
+
const es = new EventSource(`${base}/__bosia/sse`);
|
|
186
187
|
|
|
187
188
|
es.addEventListener("reload", () => {
|
|
188
189
|
console.log("[Bosia] Reloading...");
|
|
@@ -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
|
-
|
|
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 }>();
|
|
@@ -5,6 +5,13 @@
|
|
|
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";
|
|
10
|
+
|
|
11
|
+
// Everything here is a real browser path, base and all. Under a BASE_PATH mount
|
|
12
|
+
// the generated `clientRoutes` carry the prefix too, so an anchor's href, the
|
|
13
|
+
// address bar and the route table are all already in the same space — a click
|
|
14
|
+
// pushes exactly the URL that was in the link, untouched.
|
|
8
15
|
|
|
9
16
|
export type NavType = "link" | "goto" | "popstate" | "form" | "enter";
|
|
10
17
|
|
|
@@ -93,6 +100,19 @@ export const router = new (class Router {
|
|
|
93
100
|
const pathname = path.split("?")[0].split("#")[0];
|
|
94
101
|
const match = findMatch(clientRoutes, pathname);
|
|
95
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
|
+
}
|
|
96
116
|
window.location.href = path;
|
|
97
117
|
return;
|
|
98
118
|
}
|
package/src/core/cookies.ts
CHANGED
|
@@ -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
|
-
|
|
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
package/src/core/errors.ts
CHANGED
|
@@ -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
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { getDeclaredEnvKeys } from "./env.ts";
|
|
3
3
|
import { nonceAttr } from "./csp.ts";
|
|
4
4
|
import { OUT_DIR } from "./paths.ts";
|
|
5
|
+
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
6
|
+
import { currentBase } from "./appBase.ts";
|
|
5
7
|
import type { AppHtmlSegments } from "./appHtml.ts";
|
|
6
8
|
import { interpolateSegment } from "./appHtml.ts";
|
|
7
9
|
|
|
@@ -9,7 +11,13 @@ import { interpolateSegment } from "./appHtml.ts";
|
|
|
9
11
|
// Maps hashed filenames → script/link tags.
|
|
10
12
|
// Cached at startup; server restarts on rebuild in dev anyway.
|
|
11
13
|
|
|
12
|
-
export const distManifest: {
|
|
14
|
+
export const distManifest: {
|
|
15
|
+
js: string[];
|
|
16
|
+
css: string[];
|
|
17
|
+
entry: string;
|
|
18
|
+
tw?: string;
|
|
19
|
+
basePath?: string;
|
|
20
|
+
} = (() => {
|
|
13
21
|
const p = `${OUT_DIR}/manifest.json`;
|
|
14
22
|
return existsSync(p)
|
|
15
23
|
? JSON.parse(readFileSync(p, "utf-8"))
|
|
@@ -19,12 +27,32 @@ export const distManifest: { js: string[]; css: string[]; entry: string; tw?: st
|
|
|
19
27
|
export const isDev = process.env.NODE_ENV !== "production";
|
|
20
28
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
21
29
|
|
|
30
|
+
// Every URL the framework itself emits into the document, prefixed once here so
|
|
31
|
+
// mounting under a BASE_PATH is not thirteen separate string edits. All four are
|
|
32
|
+
// "" + the original path when no base is set.
|
|
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`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handed to the client bundle so its router strips the same prefix the server
|
|
41
|
+
* added. Emitted before the module script, and omitted entirely at the origin
|
|
42
|
+
* root so a root-mounted app carries no extra bytes.
|
|
43
|
+
*/
|
|
44
|
+
export function baseScript(nonce?: string): string {
|
|
45
|
+
return B
|
|
46
|
+
? `\n <script${nonceAttr(nonce)}>window.__BOSIA_BASE__=${JSON.stringify(B)};</script>`
|
|
47
|
+
: "";
|
|
48
|
+
}
|
|
49
|
+
|
|
22
50
|
/** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
|
|
23
51
|
* hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
|
|
24
52
|
function twCssLink(): string {
|
|
25
53
|
return distManifest.tw
|
|
26
|
-
? `<link rel="stylesheet" href="
|
|
27
|
-
: `<link rel="stylesheet" href="
|
|
54
|
+
? `<link rel="stylesheet" href="${DIST}/${distManifest.tw}">`
|
|
55
|
+
: `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
|
|
28
56
|
}
|
|
29
57
|
|
|
30
58
|
/** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
|
|
@@ -116,8 +144,15 @@ export function buildHtml(
|
|
|
116
144
|
bodyEndExtras?: string[],
|
|
117
145
|
segments?: AppHtmlSegments,
|
|
118
146
|
): string {
|
|
147
|
+
// An app writes <a href="/masuk">; under a base the browser has to be handed
|
|
148
|
+
// /sso/masuk or it walks off this app entirely. Only the rendered markup is
|
|
149
|
+
// touched — never the JSON data islands below, whose strings are loader
|
|
150
|
+
// output and would be corrupted by a path rewrite.
|
|
151
|
+
body = rebaseHtmlAttrs(B, body);
|
|
152
|
+
head = rebaseHtmlAttrs(B, head);
|
|
153
|
+
|
|
119
154
|
const cssLinks = (distManifest.css ?? [])
|
|
120
|
-
.map((f: string) => `<link rel="stylesheet" href="
|
|
155
|
+
.map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
|
|
121
156
|
.join("\n ");
|
|
122
157
|
|
|
123
158
|
const fallbackTitle = head.includes("<title>") ? "" : "<title>Bosia App</title>";
|
|
@@ -147,9 +182,9 @@ export function buildHtml(
|
|
|
147
182
|
: "";
|
|
148
183
|
|
|
149
184
|
const scripts = csr
|
|
150
|
-
? `${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="
|
|
185
|
+
? `${baseScript(nonce)}${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="${DIST}/${distManifest.entry}${cacheBust}"></script>`
|
|
151
186
|
: isDev
|
|
152
|
-
? `\n <script${n}>!function r(){var e=new EventSource("
|
|
187
|
+
? `\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
188
|
: "";
|
|
154
189
|
|
|
155
190
|
const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
|
|
@@ -164,7 +199,7 @@ export function buildHtml(
|
|
|
164
199
|
const tailInterpolated = interpolateSegment(segments.tail, { nonce });
|
|
165
200
|
const faviconLine = segments.hasCustomFavicon
|
|
166
201
|
? ""
|
|
167
|
-
: ` <link rel="icon" type="image/svg+xml" href="
|
|
202
|
+
: ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
|
|
168
203
|
|
|
169
204
|
return (
|
|
170
205
|
headOpenInterpolated +
|
|
@@ -185,7 +220,7 @@ export function buildHtml(
|
|
|
185
220
|
<meta charset="UTF-8">
|
|
186
221
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
187
222
|
${fallbackTitle}
|
|
188
|
-
<link rel="icon" type="image/svg+xml" href="
|
|
223
|
+
<link rel="icon" type="image/svg+xml" href="${FAVICON}">
|
|
189
224
|
${head}
|
|
190
225
|
${cssLinks}
|
|
191
226
|
${twCssLink()}
|
|
@@ -210,20 +245,20 @@ export function buildHtmlShellOpen(
|
|
|
210
245
|
const key = safeLang(lang);
|
|
211
246
|
const n = nonceAttr(nonce);
|
|
212
247
|
const cssLinks = (distManifest.css ?? [])
|
|
213
|
-
.map((f: string) => `<link rel="stylesheet" href="
|
|
248
|
+
.map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
|
|
214
249
|
.join("\n ");
|
|
215
250
|
|
|
216
251
|
if (segments) {
|
|
217
252
|
const headOpenInterpolated = interpolateSegment(segments.headOpen, { lang: key, nonce });
|
|
218
253
|
const faviconLine = segments.hasCustomFavicon
|
|
219
254
|
? ""
|
|
220
|
-
: ` <link rel="icon" type="image/svg+xml" href="
|
|
255
|
+
: ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
|
|
221
256
|
return (
|
|
222
257
|
headOpenInterpolated +
|
|
223
258
|
`\n ${faviconLine}${cssLinks}\n` +
|
|
224
259
|
` ${twCssLink()}\n` +
|
|
225
260
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
226
|
-
` <link rel="modulepreload" href="
|
|
261
|
+
` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
|
|
227
262
|
);
|
|
228
263
|
}
|
|
229
264
|
|
|
@@ -231,11 +266,11 @@ export function buildHtmlShellOpen(
|
|
|
231
266
|
`<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
|
|
232
267
|
` <meta charset="UTF-8">\n` +
|
|
233
268
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
234
|
-
` <link rel="icon" type="image/svg+xml" href="
|
|
269
|
+
` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n` +
|
|
235
270
|
` ${cssLinks}\n` +
|
|
236
271
|
` ${twCssLink()}\n` +
|
|
237
272
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
238
|
-
` <link rel="modulepreload" href="
|
|
273
|
+
` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
|
|
239
274
|
);
|
|
240
275
|
}
|
|
241
276
|
|
|
@@ -290,7 +325,9 @@ export function buildMetadataChunk(
|
|
|
290
325
|
out += `</head>\n<body>\n${SPINNER}`;
|
|
291
326
|
}
|
|
292
327
|
|
|
293
|
-
|
|
328
|
+
// All markup, no data islands — safe to rebase wholesale, which is what picks
|
|
329
|
+
// up an app's own headExtras (a canonical link, an og:image on a local file).
|
|
330
|
+
return rebaseHtmlAttrs(B, out);
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
export function escapeHtml(s: string): string {
|
|
@@ -319,12 +356,17 @@ export function buildHtmlTail(
|
|
|
319
356
|
layoutDeps: any[] | null = null,
|
|
320
357
|
segments?: AppHtmlSegments,
|
|
321
358
|
): string {
|
|
359
|
+
// Same rebase as buildHtml — the streamed tail carries the identical markup.
|
|
360
|
+
body = rebaseHtmlAttrs(B, body);
|
|
361
|
+
head = rebaseHtmlAttrs(B, head);
|
|
362
|
+
|
|
322
363
|
const n = nonceAttr(nonce);
|
|
323
364
|
let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
|
|
324
365
|
out += `\n<div id="app">${body}</div>`;
|
|
325
366
|
if (head)
|
|
326
367
|
out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
|
|
327
368
|
if (csr) {
|
|
369
|
+
out += baseScript(nonce);
|
|
328
370
|
const publicEnv = getPublicDynamicEnv();
|
|
329
371
|
if (Object.keys(publicEnv).length > 0) {
|
|
330
372
|
out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
@@ -342,9 +384,9 @@ export function buildHtmlTail(
|
|
|
342
384
|
if (ssrFlag || depsInject) {
|
|
343
385
|
out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
|
|
344
386
|
}
|
|
345
|
-
out += `\n<script${n} type="module" src="
|
|
387
|
+
out += `\n<script${n} type="module" src="${DIST}/${distManifest.entry}${cacheBust}"></script>`;
|
|
346
388
|
} else if (isDev) {
|
|
347
|
-
out += `\n<script${n}>!function r(){var e=new EventSource("
|
|
389
|
+
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
390
|
}
|
|
349
391
|
if (bodyEndExtras?.length) {
|
|
350
392
|
for (const fragment of bodyEndExtras) {
|
package/src/core/prerender.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from "path";
|
|
|
4
4
|
import type { RouteManifest, TrailingSlash } from "./types.ts";
|
|
5
5
|
|
|
6
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> {
|
|
@@ -184,7 +185,10 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
184
185
|
try {
|
|
185
186
|
// Poll /_health until ready (max 10s). Check first, sleep only on failure —
|
|
186
187
|
// avoids a guaranteed floor when the server is already up.
|
|
187
|
-
|
|
188
|
+
// The child inherits BASE_PATH, so it 404s anything outside the mount —
|
|
189
|
+
// including /_health. Prefix here and every fetch below follows; the files
|
|
190
|
+
// we write stay app-space, which is what the server looks them up by.
|
|
191
|
+
const base = `http://localhost:${port}${currentBase()}`;
|
|
188
192
|
let ready = false;
|
|
189
193
|
const deadline = Date.now() + 10_000;
|
|
190
194
|
while (Date.now() < deadline) {
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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}],`);
|
package/src/core/server.ts
CHANGED
|
@@ -21,9 +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
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
|
+
import { stripBase, withBase } from "./basePath.ts";
|
|
28
|
+
import { currentBase } from "./appBase.ts";
|
|
27
29
|
import { pidsOnPort } from "./port.ts";
|
|
28
30
|
import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
29
31
|
import { dedup } from "./dedup.ts";
|
|
@@ -158,6 +160,19 @@ if (CSP_DIRECTIVES_TEMPLATE) {
|
|
|
158
160
|
console.log(`🔒 CSP: opt-in header active`);
|
|
159
161
|
}
|
|
160
162
|
|
|
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
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
// ─── Core Request Resolver ────────────────────────────────
|
|
162
177
|
// This is the inner handler that hooks wrap around.
|
|
163
178
|
|
|
@@ -632,7 +647,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
632
647
|
if (canonical !== null) {
|
|
633
648
|
return new Response(null, {
|
|
634
649
|
status: 308,
|
|
635
|
-
|
|
650
|
+
// `path` is app-space — the base came off at the top of handleRequest.
|
|
651
|
+
// This Location goes back to the browser, so it has to be put back on,
|
|
652
|
+
// or a `trailingSlash: "always"` route 308s every request off the mount.
|
|
653
|
+
headers: { Location: withBase(currentBase(), canonical) + url.search + url.hash },
|
|
636
654
|
});
|
|
637
655
|
}
|
|
638
656
|
}
|
|
@@ -864,6 +882,17 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
864
882
|
if (fwdProto) url.protocol = `${fwdProto}:`;
|
|
865
883
|
}
|
|
866
884
|
|
|
885
|
+
// Mounted under BASE_PATH? Everything downstream — hooks, the router, the
|
|
886
|
+
// /_health and /__bosia tests below — works in app space, so the prefix comes
|
|
887
|
+
// off exactly once, here, before anything reads a pathname. A request that is
|
|
888
|
+
// not under the base was never this app's to answer.
|
|
889
|
+
const base = currentBase();
|
|
890
|
+
if (base) {
|
|
891
|
+
const appPath = stripBase(base, url.pathname);
|
|
892
|
+
if (appPath === null) return new Response("Not Found", { status: 404 });
|
|
893
|
+
url.pathname = appPath;
|
|
894
|
+
}
|
|
895
|
+
|
|
867
896
|
// Reject new non-health requests during shutdown
|
|
868
897
|
if (shuttingDown && url.pathname !== "/_health") {
|
|
869
898
|
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,
|
package/src/core/twHash.ts
CHANGED
|
@@ -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 { currentBase } from "./appBase.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,20 @@ 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
|
+
//
|
|
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"));
|
|
26
|
+
writeFileSync(tempPath, rebased);
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
const bytes = readFileSync(tempPath);
|
|
14
30
|
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
|
|
15
31
|
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.
|