bosia 0.9.0 → 0.9.2
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/basePath.ts +45 -11
- package/src/core/build.ts +4 -0
- package/src/core/client/base.ts +4 -8
- package/src/core/client/hydrate.ts +2 -1
- package/src/core/client/router.svelte.ts +15 -0
- package/src/core/html.ts +60 -41
- package/src/core/paths.ts +0 -10
- package/src/core/prerender.ts +3 -2
- package/src/core/renderer.ts +68 -10
- package/src/core/server.ts +18 -7
- package/src/core/twHash.ts +8 -3
package/package.json
CHANGED
package/src/core/basePath.ts
CHANGED
|
@@ -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
|
|
57
|
-
* writes `<a href="/masuk">` keeps working under a base with no code
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(", ")}`);
|
package/src/core/client/base.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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`
|
|
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 =
|
|
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(
|
|
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,16 +1,24 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { getDeclaredEnvKeys } from "./env.ts";
|
|
3
3
|
import { nonceAttr } from "./csp.ts";
|
|
4
|
-
import {
|
|
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";
|
|
9
|
+
import type { Metadata } from "./hooks.ts";
|
|
8
10
|
|
|
9
11
|
// ─── Dist Manifest ───────────────────────────────────────
|
|
10
12
|
// Maps hashed filenames → script/link tags.
|
|
11
13
|
// Cached at startup; server restarts on rebuild in dev anyway.
|
|
12
14
|
|
|
13
|
-
export const distManifest: {
|
|
15
|
+
export const distManifest: {
|
|
16
|
+
js: string[];
|
|
17
|
+
css: string[];
|
|
18
|
+
entry: string;
|
|
19
|
+
tw?: string;
|
|
20
|
+
basePath?: string;
|
|
21
|
+
} = (() => {
|
|
14
22
|
const p = `${OUT_DIR}/manifest.json`;
|
|
15
23
|
return existsSync(p)
|
|
16
24
|
? JSON.parse(readFileSync(p, "utf-8"))
|
|
@@ -23,10 +31,11 @@ const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
|
23
31
|
// Every URL the framework itself emits into the document, prefixed once here so
|
|
24
32
|
// mounting under a BASE_PATH is not thirteen separate string edits. All four are
|
|
25
33
|
// "" + the original path when no base is set.
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
34
|
+
const B = currentBase();
|
|
35
|
+
const DIST = `${B}/dist/client`;
|
|
36
|
+
const TW_CSS = `${B}/bosia-tw.css`;
|
|
37
|
+
const FAVICON = `${B}/favicon.svg`;
|
|
38
|
+
const SSE = `${B}/__bosia/sse`;
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* Handed to the client bundle so its router strips the same prefix the server
|
|
@@ -34,8 +43,8 @@ const SSE = `${BASE_PATH}/__bosia/sse`;
|
|
|
34
43
|
* root so a root-mounted app carries no extra bytes.
|
|
35
44
|
*/
|
|
36
45
|
export function baseScript(nonce?: string): string {
|
|
37
|
-
return
|
|
38
|
-
? `\n <script${nonceAttr(nonce)}>window.__BOSIA_BASE__=${JSON.stringify(
|
|
46
|
+
return B
|
|
47
|
+
? `\n <script${nonceAttr(nonce)}>window.__BOSIA_BASE__=${JSON.stringify(B)};</script>`
|
|
39
48
|
: "";
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -135,19 +144,25 @@ export function buildHtml(
|
|
|
135
144
|
layoutDeps: any[] | null = null,
|
|
136
145
|
bodyEndExtras?: string[],
|
|
137
146
|
segments?: AppHtmlSegments,
|
|
147
|
+
metadata?: Metadata | null,
|
|
138
148
|
): string {
|
|
139
149
|
// An app writes <a href="/masuk">; under a base the browser has to be handed
|
|
140
150
|
// /sso/masuk or it walks off this app entirely. Only the rendered markup is
|
|
141
151
|
// touched — never the JSON data islands below, whose strings are loader
|
|
142
152
|
// output and would be corrupted by a path rewrite.
|
|
143
|
-
body = rebaseHtmlAttrs(
|
|
144
|
-
head = rebaseHtmlAttrs(
|
|
153
|
+
body = rebaseHtmlAttrs(B, body);
|
|
154
|
+
head = rebaseHtmlAttrs(B, head);
|
|
145
155
|
|
|
146
156
|
const cssLinks = (distManifest.css ?? [])
|
|
147
157
|
.map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
|
|
148
158
|
.join("\n ");
|
|
149
159
|
|
|
150
|
-
|
|
160
|
+
// Metadata goes in before `head`: the first <title> in the document wins, and
|
|
161
|
+
// the streaming path already puts metadata() ahead of <svelte:head> content
|
|
162
|
+
// (which arrives later via buildHtmlTail). Same order = same winner on both paths.
|
|
163
|
+
const metaTags = rebaseHtmlAttrs(B, metadataTags(metadata ?? null));
|
|
164
|
+
const fallbackTitle =
|
|
165
|
+
metaTags.includes("<title>") || head.includes("<title>") ? "" : "<title>Bosia App</title>";
|
|
151
166
|
|
|
152
167
|
const n = nonceAttr(nonce);
|
|
153
168
|
const publicEnv = getPublicDynamicEnv();
|
|
@@ -198,7 +213,7 @@ export function buildHtml(
|
|
|
198
213
|
`\n ${faviconLine}${cssLinks}\n` +
|
|
199
214
|
` ${twCssLink()}\n` +
|
|
200
215
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
201
|
-
` ${fallbackTitle}${head}` +
|
|
216
|
+
` ${fallbackTitle}${metaTags}${head}` +
|
|
202
217
|
headCloseInterpolated +
|
|
203
218
|
(body ? "" : `\n${SPINNER}`) +
|
|
204
219
|
`\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
|
|
@@ -213,7 +228,7 @@ export function buildHtml(
|
|
|
213
228
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
214
229
|
${fallbackTitle}
|
|
215
230
|
<link rel="icon" type="image/svg+xml" href="${FAVICON}">
|
|
216
|
-
${head}
|
|
231
|
+
${metaTags} ${head}
|
|
217
232
|
${cssLinks}
|
|
218
233
|
${twCssLink()}
|
|
219
234
|
<script${n}>${THEME_INIT_JS}</script>
|
|
@@ -226,8 +241,6 @@ export function buildHtml(
|
|
|
226
241
|
|
|
227
242
|
// ─── Streaming HTML Helpers ──────────────────────────────
|
|
228
243
|
|
|
229
|
-
import type { Metadata } from "./hooks.ts";
|
|
230
|
-
|
|
231
244
|
/** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
|
|
232
245
|
export function buildHtmlShellOpen(
|
|
233
246
|
lang?: string,
|
|
@@ -274,6 +287,34 @@ const SPINNER =
|
|
|
274
287
|
`border-radius:50%;animation:__bs__ .8s linear infinite}` +
|
|
275
288
|
`@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
|
|
276
289
|
|
|
290
|
+
/** The `metadata()` tags themselves, indented head-ready. Shared by the streaming
|
|
291
|
+
* path (buildMetadataChunk) and the non-streaming one (buildHtml) so the two
|
|
292
|
+
* renderers cannot drift on what `metadata()` emits. */
|
|
293
|
+
export function metadataTags(metadata: Metadata | null): string {
|
|
294
|
+
if (!metadata) return "";
|
|
295
|
+
let out = "";
|
|
296
|
+
if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
|
|
297
|
+
if (metadata.description) {
|
|
298
|
+
out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
|
|
299
|
+
}
|
|
300
|
+
if (metadata.meta) {
|
|
301
|
+
for (const m of metadata.meta) {
|
|
302
|
+
const attrs = m.name
|
|
303
|
+
? `name="${escapeAttr(m.name)}"`
|
|
304
|
+
: `property="${escapeAttr(m.property ?? "")}"`;
|
|
305
|
+
out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (metadata.link) {
|
|
309
|
+
for (const l of metadata.link) {
|
|
310
|
+
let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
|
|
311
|
+
if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
|
|
312
|
+
out += ` <link ${attrs}>\n`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
277
318
|
/** Chunk 2: metadata tags + close </head> + open <body> + spinner */
|
|
278
319
|
export function buildMetadataChunk(
|
|
279
320
|
metadata: Metadata | null,
|
|
@@ -281,29 +322,7 @@ export function buildMetadataChunk(
|
|
|
281
322
|
segments?: AppHtmlSegments,
|
|
282
323
|
): string {
|
|
283
324
|
let out = "\n";
|
|
284
|
-
|
|
285
|
-
if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
|
|
286
|
-
if (metadata.description) {
|
|
287
|
-
out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
|
|
288
|
-
}
|
|
289
|
-
if (metadata.meta) {
|
|
290
|
-
for (const m of metadata.meta) {
|
|
291
|
-
const attrs = m.name
|
|
292
|
-
? `name="${escapeAttr(m.name)}"`
|
|
293
|
-
: `property="${escapeAttr(m.property ?? "")}"`;
|
|
294
|
-
out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
if (metadata.link) {
|
|
298
|
-
for (const l of metadata.link) {
|
|
299
|
-
let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
|
|
300
|
-
if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
|
|
301
|
-
out += ` <link ${attrs}>\n`;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
} else {
|
|
305
|
-
out += ` <title>Bosia App</title>\n`;
|
|
306
|
-
}
|
|
325
|
+
out += metadata ? metadataTags(metadata) : ` <title>Bosia App</title>\n`;
|
|
307
326
|
if (headExtras?.length) {
|
|
308
327
|
for (const fragment of headExtras) {
|
|
309
328
|
if (fragment) out += ` ${fragment}\n`;
|
|
@@ -319,7 +338,7 @@ export function buildMetadataChunk(
|
|
|
319
338
|
|
|
320
339
|
// All markup, no data islands — safe to rebase wholesale, which is what picks
|
|
321
340
|
// up an app's own headExtras (a canonical link, an og:image on a local file).
|
|
322
|
-
return rebaseHtmlAttrs(
|
|
341
|
+
return rebaseHtmlAttrs(B, out);
|
|
323
342
|
}
|
|
324
343
|
|
|
325
344
|
export function escapeHtml(s: string): string {
|
|
@@ -349,8 +368,8 @@ export function buildHtmlTail(
|
|
|
349
368
|
segments?: AppHtmlSegments,
|
|
350
369
|
): string {
|
|
351
370
|
// Same rebase as buildHtml — the streamed tail carries the identical markup.
|
|
352
|
-
body = rebaseHtmlAttrs(
|
|
353
|
-
head = rebaseHtmlAttrs(
|
|
371
|
+
body = rebaseHtmlAttrs(B, body);
|
|
372
|
+
head = rebaseHtmlAttrs(B, head);
|
|
354
373
|
|
|
355
374
|
const n = nonceAttr(nonce);
|
|
356
375
|
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) {
|
package/src/core/prerender.ts
CHANGED
|
@@ -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 {
|
|
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}${
|
|
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) {
|
package/src/core/renderer.ts
CHANGED
|
@@ -509,6 +509,9 @@ export async function loadMetadata(
|
|
|
509
509
|
);
|
|
510
510
|
}
|
|
511
511
|
} catch (err) {
|
|
512
|
+
// Control flow thrown from metadata() is intent, not failure — swallowing it
|
|
513
|
+
// here made every caller's Redirect/HttpError branch dead code.
|
|
514
|
+
if (err instanceof Redirect || err instanceof HttpError) throw err;
|
|
512
515
|
if (isDev) console.error("Metadata load error:", err);
|
|
513
516
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
514
517
|
if (isDev) reportDevErrorFromCatch(err);
|
|
@@ -845,7 +848,9 @@ export async function renderSSRStream(
|
|
|
845
848
|
|
|
846
849
|
// ─── Form Action Page Renderer ───────────────────────────
|
|
847
850
|
// Re-runs load functions after a form action, renders with form data.
|
|
848
|
-
// Uses non-streaming buildHtml so we can control the status code.
|
|
851
|
+
// Uses non-streaming buildHtml so we can control the status code. Resolves
|
|
852
|
+
// metadata() here too — the GET path's title, meta/link tags, lang and
|
|
853
|
+
// metadata.data must survive a submit, or the page changes under the user.
|
|
849
854
|
|
|
850
855
|
export async function renderPageWithFormData(
|
|
851
856
|
url: URL,
|
|
@@ -871,11 +876,36 @@ export async function renderPageWithFormData(
|
|
|
871
876
|
nonce,
|
|
872
877
|
);
|
|
873
878
|
|
|
874
|
-
const { route } = match;
|
|
879
|
+
const { route, params } = match;
|
|
880
|
+
|
|
881
|
+
// Serial, not parallel: metadata.data feeds the loader below.
|
|
882
|
+
let metadata: Metadata | null = null;
|
|
883
|
+
try {
|
|
884
|
+
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (err instanceof Redirect) return Response.redirect(err.location, err.status);
|
|
887
|
+
if (err instanceof HttpError) {
|
|
888
|
+
return renderErrorPage(
|
|
889
|
+
err.status,
|
|
890
|
+
err.message,
|
|
891
|
+
url,
|
|
892
|
+
req,
|
|
893
|
+
route,
|
|
894
|
+
undefined,
|
|
895
|
+
undefined,
|
|
896
|
+
undefined,
|
|
897
|
+
nonce,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
if (isDev) console.error("Metadata load error:", err);
|
|
901
|
+
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
902
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
903
|
+
// Continue with null metadata — don't break the page for a metadata failure
|
|
904
|
+
}
|
|
875
905
|
|
|
876
906
|
// Load components + data in parallel
|
|
877
907
|
const [data, pageMod, layoutMods] = await Promise.all([
|
|
878
|
-
loadRouteData(url, locals, req, cookies, null, match),
|
|
908
|
+
loadRouteData(url, locals, req, cookies, metadata?.data ?? null, match),
|
|
879
909
|
route.pageModule(),
|
|
880
910
|
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
881
911
|
]);
|
|
@@ -893,6 +923,23 @@ export async function renderPageWithFormData(
|
|
|
893
923
|
nonce,
|
|
894
924
|
);
|
|
895
925
|
|
|
926
|
+
const renderCtx: RenderContext = {
|
|
927
|
+
request: req,
|
|
928
|
+
url,
|
|
929
|
+
route: { pattern: route.pattern },
|
|
930
|
+
metadata,
|
|
931
|
+
};
|
|
932
|
+
const [headExtras, bodyEndExtras] = await Promise.all([
|
|
933
|
+
pluginRenderFragments("head", renderCtx),
|
|
934
|
+
pluginRenderFragments("bodyEnd", renderCtx),
|
|
935
|
+
]);
|
|
936
|
+
// buildHtml has no headExtras slot; fold them in ahead of the SSR head, which
|
|
937
|
+
// is where buildMetadataChunk puts them on the streaming path.
|
|
938
|
+
const headWithExtras = (ssrHead: string) => {
|
|
939
|
+
const extras = headExtras.filter(Boolean);
|
|
940
|
+
return extras.length ? `${extras.join("\n ")}\n ${ssrHead}` : ssrHead;
|
|
941
|
+
};
|
|
942
|
+
|
|
896
943
|
// Form-action re-render always runs every loader (no client mask).
|
|
897
944
|
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
898
945
|
const pageDataFull = data.pageData ?? {};
|
|
@@ -905,18 +952,19 @@ export async function renderPageWithFormData(
|
|
|
905
952
|
}
|
|
906
953
|
const html = buildHtml(
|
|
907
954
|
"",
|
|
908
|
-
"",
|
|
955
|
+
headWithExtras(""),
|
|
909
956
|
pageDataFull,
|
|
910
957
|
layoutDataFull,
|
|
911
958
|
true,
|
|
912
959
|
formData,
|
|
913
|
-
|
|
960
|
+
metadata?.lang,
|
|
914
961
|
false,
|
|
915
962
|
nonce,
|
|
916
963
|
data.pageDeps,
|
|
917
964
|
data.layoutDeps,
|
|
918
|
-
|
|
965
|
+
bodyEndExtras,
|
|
919
966
|
appHtmlSegments,
|
|
967
|
+
metadata,
|
|
920
968
|
);
|
|
921
969
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
922
970
|
}
|
|
@@ -934,18 +982,19 @@ export async function renderPageWithFormData(
|
|
|
934
982
|
|
|
935
983
|
const html = buildHtml(
|
|
936
984
|
body,
|
|
937
|
-
head,
|
|
985
|
+
headWithExtras(head),
|
|
938
986
|
pageDataFull,
|
|
939
987
|
layoutDataFull,
|
|
940
988
|
data.csr,
|
|
941
989
|
formData,
|
|
942
|
-
|
|
990
|
+
metadata?.lang,
|
|
943
991
|
true,
|
|
944
992
|
nonce,
|
|
945
993
|
data.pageDeps,
|
|
946
994
|
data.layoutDeps,
|
|
947
|
-
|
|
995
|
+
bodyEndExtras,
|
|
948
996
|
appHtmlSegments,
|
|
997
|
+
metadata,
|
|
949
998
|
);
|
|
950
999
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
951
1000
|
}
|
|
@@ -971,6 +1020,13 @@ export async function renderErrorPage(
|
|
|
971
1020
|
// is dead bytes without a matching policy header.
|
|
972
1021
|
if (!CSP_ENABLED) nonce = undefined;
|
|
973
1022
|
|
|
1023
|
+
// The route's own metadata() is deliberately NOT run here — the route may be
|
|
1024
|
+
// what failed, and a 404 has no route at all. Synthesize a status title only
|
|
1025
|
+
// when the error component didn't set one, so <svelte:head><title> in a custom
|
|
1026
|
+
// +error.svelte still wins.
|
|
1027
|
+
const errMeta = (head: string): Metadata | null =>
|
|
1028
|
+
head.includes("<title>") ? null : { title: `${status} — ${message}` };
|
|
1029
|
+
|
|
974
1030
|
// Inspector overlay and other plugin bodyEnd fragments must be injected
|
|
975
1031
|
// on error pages too — otherwise SSE never connects and runtime errors
|
|
976
1032
|
// from the failing render are invisible in the UI.
|
|
@@ -978,7 +1034,7 @@ export async function renderErrorPage(
|
|
|
978
1034
|
request: req,
|
|
979
1035
|
url,
|
|
980
1036
|
route: route ? { pattern: route.pattern } : { pattern: "" },
|
|
981
|
-
metadata:
|
|
1037
|
+
metadata: errMeta(""),
|
|
982
1038
|
};
|
|
983
1039
|
const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
|
|
984
1040
|
|
|
@@ -1024,6 +1080,7 @@ export async function renderErrorPage(
|
|
|
1024
1080
|
null,
|
|
1025
1081
|
bodyEndExtras,
|
|
1026
1082
|
appHtmlSegments,
|
|
1083
|
+
errMeta(head),
|
|
1027
1084
|
);
|
|
1028
1085
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
1029
1086
|
} catch (err) {
|
|
@@ -1059,6 +1116,7 @@ export async function renderErrorPage(
|
|
|
1059
1116
|
null,
|
|
1060
1117
|
bodyEndExtras,
|
|
1061
1118
|
appHtmlSegments,
|
|
1119
|
+
errMeta(head),
|
|
1062
1120
|
);
|
|
1063
1121
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
1064
1122
|
} catch (err) {
|
package/src/core/server.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
163
|
-
console.log(`📍 Mounted under ${
|
|
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(
|
|
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
|
-
|
|
880
|
-
|
|
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
|
}
|
package/src/core/twHash.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|