bosia 0.3.1 → 0.3.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/ambient.d.ts +1 -0
- package/src/core/dedup.ts +16 -19
- package/src/core/dev.ts +33 -13
- package/src/core/prerender.ts +100 -65
- package/src/core/renderer.ts +62 -54
- package/src/core/routeFile.ts +2 -0
- package/src/core/scanner.ts +7 -1
- package/src/core/server.ts +13 -7
- package/src/core/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
package/src/ambient.d.ts
CHANGED
package/src/core/dedup.ts
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// ─── Request Deduplication ──────────────────────────────
|
|
2
|
+
// Concurrent in-flight coalescing for public routes. When N parallel requests
|
|
3
|
+
// hit the same URL, run the loader once and share the resolved value across
|
|
4
|
+
// all N waiters. Settled responses are NOT cached — once the promise resolves,
|
|
5
|
+
// the entry is dropped, so the next request runs the loader again.
|
|
6
|
+
//
|
|
7
|
+
// Scope decision lives in the scanner: routes under a `(private)` group skip
|
|
8
|
+
// dedup entirely. Per-user routes MUST be private — sharing a loader result
|
|
9
|
+
// across users would leak data. See docs/guides/request-deduplication.md.
|
|
3
10
|
|
|
4
|
-
const inflight = new Map<string, Promise<
|
|
11
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/** Build dedup key from route URL + request identity. Sort search params for consistency. */
|
|
9
|
-
export function dedupKey(url: URL, request: Request): string {
|
|
13
|
+
/** Build a stable dedup key from a URL: normalized path + sorted query. */
|
|
14
|
+
export function dedupKey(url: URL): string {
|
|
10
15
|
let path = url.pathname;
|
|
11
16
|
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
12
17
|
const sorted = new URLSearchParams([...url.searchParams.entries()].sort());
|
|
13
18
|
const search = sorted.toString();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const authHeader = request.headers.get("authorization") ?? "";
|
|
17
|
-
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
18
|
-
const match = cookieHeader.match(AUTH_COOKIE_RE);
|
|
19
|
-
const authCookie = match?.[1] ?? "";
|
|
20
|
-
const identity = authHeader || authCookie;
|
|
21
|
-
if (!identity) return base;
|
|
22
|
-
return `${base}|${Bun.hash(identity).toString(36)}`;
|
|
19
|
+
return search ? `${path}?${search}` : path;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
/**
|
|
22
|
+
/** Coalesce concurrent calls under `key`. `fn` runs at most once per inflight window. */
|
|
26
23
|
export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
27
24
|
const existing = inflight.get(key);
|
|
28
|
-
if (existing) return existing
|
|
25
|
+
if (existing) return existing as Promise<T>;
|
|
29
26
|
const promise = fn().finally(() => inflight.delete(key));
|
|
30
27
|
inflight.set(key, promise);
|
|
31
|
-
return promise
|
|
28
|
+
return promise as Promise<T>;
|
|
32
29
|
}
|
package/src/core/dev.ts
CHANGED
|
@@ -137,22 +137,21 @@ async function buildAndRestart() {
|
|
|
137
137
|
}
|
|
138
138
|
building = true;
|
|
139
139
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
do {
|
|
141
|
+
buildPending = false;
|
|
142
|
+
const ok = await runBuild();
|
|
143
|
+
if (!ok) {
|
|
144
|
+
console.error("❌ Build failed — fix errors and save again");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await startAppServer();
|
|
148
|
+
// Give the app server a moment to bind its port
|
|
149
|
+
await Bun.sleep(200);
|
|
150
|
+
broadcastReload();
|
|
151
|
+
} while (buildPending);
|
|
149
152
|
} finally {
|
|
150
153
|
building = false;
|
|
151
154
|
}
|
|
152
|
-
if (buildPending) {
|
|
153
|
-
buildPending = false;
|
|
154
|
-
buildAndRestart();
|
|
155
|
-
}
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
function scheduleBuild() {
|
|
@@ -266,3 +265,24 @@ watch(process.cwd(), { recursive: false }, (_event, filename) => {
|
|
|
266
265
|
});
|
|
267
266
|
|
|
268
267
|
console.log("👀 Watching src/ for changes...\n");
|
|
268
|
+
|
|
269
|
+
// ─── Shutdown ─────────────────────────────────────────────
|
|
270
|
+
// Own SIGINT/SIGTERM so we can cleanly stop the child app server.
|
|
271
|
+
// Without this, the terminal's ^C reaches both processes; the parent
|
|
272
|
+
// exits instantly while the child blocks in `app.stop()`, requiring
|
|
273
|
+
// a second ^C.
|
|
274
|
+
|
|
275
|
+
let shuttingDown = false;
|
|
276
|
+
async function shutdown() {
|
|
277
|
+
if (shuttingDown) process.exit(130);
|
|
278
|
+
shuttingDown = true;
|
|
279
|
+
intentionalKill = true;
|
|
280
|
+
if (appProcess) {
|
|
281
|
+
appProcess.kill("SIGTERM");
|
|
282
|
+
await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
|
|
283
|
+
}
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
process.on("SIGINT", shutdown);
|
|
288
|
+
process.on("SIGTERM", shutdown);
|
package/src/core/prerender.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
|
|
2
|
+
import { createServer } from "net";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
import type { RouteManifest, TrailingSlash } from "./types.ts";
|
|
4
5
|
|
|
5
6
|
import { BOSIA_NODE_PATH } from "./paths.ts";
|
|
6
7
|
|
|
8
|
+
/** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
|
|
9
|
+
export function getEphemeralPort(): Promise<number> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const srv = createServer();
|
|
12
|
+
srv.unref();
|
|
13
|
+
srv.on("error", reject);
|
|
14
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
15
|
+
const addr = srv.address();
|
|
16
|
+
if (!addr || typeof addr === "string") {
|
|
17
|
+
srv.close();
|
|
18
|
+
reject(new Error("Failed to acquire ephemeral port"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const port = addr.port;
|
|
22
|
+
srv.close(() => resolve(port));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
const CORE_DIR = import.meta.dir;
|
|
8
28
|
|
|
9
29
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
@@ -104,7 +124,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
104
124
|
|
|
105
125
|
console.log(`\n🖨️ Prerendering ${targets.length} route(s)...`);
|
|
106
126
|
|
|
107
|
-
const port =
|
|
127
|
+
const port = await getEphemeralPort();
|
|
108
128
|
const child = Bun.spawn(["bun", "run", "./dist/server/index.js"], {
|
|
109
129
|
env: {
|
|
110
130
|
...process.env,
|
|
@@ -116,78 +136,93 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
116
136
|
stderr: "ignore",
|
|
117
137
|
});
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
140
|
+
let receivedSignal: NodeJS.Signals | null = null;
|
|
141
|
+
const onSignal = (sig: NodeJS.Signals) => {
|
|
142
|
+
receivedSignal = sig;
|
|
143
|
+
};
|
|
144
|
+
for (const sig of signals) process.once(sig, onSignal);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Poll /_health until ready (max 10s)
|
|
148
|
+
const base = `http://localhost:${port}`;
|
|
149
|
+
let ready = false;
|
|
150
|
+
for (let i = 0; i < 50; i++) {
|
|
151
|
+
await Bun.sleep(200);
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${base}/_health`);
|
|
154
|
+
if (res.ok) {
|
|
155
|
+
ready = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* not ready yet */
|
|
129
160
|
}
|
|
130
|
-
} catch {
|
|
131
|
-
/* not ready yet */
|
|
132
161
|
}
|
|
133
|
-
}
|
|
134
162
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
if (!ready) {
|
|
164
|
+
console.error("❌ Prerender server failed to start");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
mkdirSync("./dist/prerendered", { recursive: true });
|
|
140
169
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const res = await fetch(`${base}${canonicalRoute}`, {
|
|
149
|
-
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
150
|
-
});
|
|
151
|
-
const html = await res.text();
|
|
152
|
-
|
|
153
|
-
// Filename strategy:
|
|
154
|
-
// never → about.html (canonical /about, served by static host as /about → about.html)
|
|
155
|
-
// always → about/index.html (canonical /about/, static host serves /about/ → about/index.html)
|
|
156
|
-
// ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
|
|
157
|
-
// root → index.html
|
|
158
|
-
const outPath = prerenderOutPath(routePath, ts);
|
|
159
|
-
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
160
|
-
writeFileSync(outPath, html);
|
|
161
|
-
|
|
162
|
-
// Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
|
|
163
|
-
const dataPath = prerenderDataPath(routePath);
|
|
164
|
-
const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
|
|
165
|
-
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
166
|
-
});
|
|
167
|
-
if (dataRes.ok) {
|
|
168
|
-
const dataJson = await dataRes.text();
|
|
169
|
-
const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
|
|
170
|
-
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
171
|
-
recursive: true,
|
|
170
|
+
for (const { path: routePath, trailingSlash: ts } of targets) {
|
|
171
|
+
try {
|
|
172
|
+
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
173
|
+
const canonicalRoute = canonicalRouteFor(routePath, ts);
|
|
174
|
+
|
|
175
|
+
const res = await fetch(`${base}${canonicalRoute}`, {
|
|
176
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
172
177
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
const html = await res.text();
|
|
179
|
+
|
|
180
|
+
// Filename strategy:
|
|
181
|
+
// never → about.html (canonical /about, served by static host as /about → about.html)
|
|
182
|
+
// always → about/index.html (canonical /about/, static host serves /about/ → about/index.html)
|
|
183
|
+
// ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
|
|
184
|
+
// root → index.html
|
|
185
|
+
const outPath = prerenderOutPath(routePath, ts);
|
|
186
|
+
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
187
|
+
writeFileSync(outPath, html);
|
|
188
|
+
|
|
189
|
+
// Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
|
|
190
|
+
const dataPath = prerenderDataPath(routePath);
|
|
191
|
+
const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
|
|
192
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
193
|
+
});
|
|
194
|
+
if (dataRes.ok) {
|
|
195
|
+
const dataJson = await dataRes.text();
|
|
196
|
+
const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
|
|
197
|
+
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
198
|
+
recursive: true,
|
|
199
|
+
});
|
|
200
|
+
writeFileSync(dataOutPath, dataJson);
|
|
201
|
+
console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
|
|
202
|
+
} else {
|
|
203
|
+
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
207
|
+
console.error(
|
|
208
|
+
` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`,
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
212
|
+
}
|
|
185
213
|
}
|
|
186
214
|
}
|
|
187
|
-
}
|
|
188
215
|
|
|
189
|
-
|
|
190
|
-
|
|
216
|
+
console.log("✅ Prerendering complete");
|
|
217
|
+
} finally {
|
|
218
|
+
for (const sig of signals) process.off(sig, onSignal);
|
|
219
|
+
child.kill();
|
|
220
|
+
await child.exited;
|
|
221
|
+
if (receivedSignal) {
|
|
222
|
+
// Re-raise so parent exit code is 128+signum (standard Unix convention)
|
|
223
|
+
process.kill(process.pid, receivedSignal);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
// ─── Static Site Output ──────────────────────────────────
|
package/src/core/renderer.ts
CHANGED
|
@@ -276,65 +276,73 @@ export async function renderSSRStream(
|
|
|
276
276
|
|
|
277
277
|
const enc = new TextEncoder();
|
|
278
278
|
|
|
279
|
+
// ssr=false → no render() needed; ship shell + hydration as a single response.
|
|
280
|
+
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
281
|
+
if (!data.ssr) {
|
|
282
|
+
if (!data.csr && isDev) {
|
|
283
|
+
console.warn(
|
|
284
|
+
`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const html =
|
|
288
|
+
buildHtmlShellOpen(metadata?.lang) +
|
|
289
|
+
buildMetadataChunk(metadata) +
|
|
290
|
+
buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false);
|
|
291
|
+
return new Response(html, {
|
|
292
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Render-first: run render() before committing to a 200. Failure → proper error page
|
|
297
|
+
// with correct status code, instead of a bare <p> mixed into an already-flushed shell.
|
|
298
|
+
let body: string, head: string;
|
|
299
|
+
try {
|
|
300
|
+
({ body, head } = render(App, {
|
|
301
|
+
props: {
|
|
302
|
+
ssrMode: true,
|
|
303
|
+
ssrPageComponent: pageMod.default,
|
|
304
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
305
|
+
ssrPageData: data.pageData,
|
|
306
|
+
ssrLayoutData: data.layoutData,
|
|
307
|
+
},
|
|
308
|
+
}));
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (isDev) console.error("SSR render error:", err);
|
|
311
|
+
else console.error("SSR render error:", (err as Error).message ?? err);
|
|
312
|
+
return renderErrorPage(500, "Internal Server Error", url, req);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Pre-compute all chunks; pull-based stream gives Bun native backpressure.
|
|
316
|
+
const chunks: Uint8Array[] = [
|
|
317
|
+
enc.encode(buildHtmlShellOpen(metadata?.lang)),
|
|
318
|
+
enc.encode(buildMetadataChunk(metadata)),
|
|
319
|
+
enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)),
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
let i = 0;
|
|
323
|
+
let cancelled = false;
|
|
324
|
+
const onAbort = () => {
|
|
325
|
+
cancelled = true;
|
|
326
|
+
};
|
|
327
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
328
|
+
|
|
279
329
|
const stream = new ReadableStream<Uint8Array>({
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
controller.enqueue(enc.encode(buildHtmlShellOpen(metadata?.lang)));
|
|
283
|
-
|
|
284
|
-
// Chunk 2: metadata tags, close </head>, open <body> + spinner
|
|
285
|
-
controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
if (!data!.ssr) {
|
|
289
|
-
// ssr=false → skip render(); ship empty shell + hydration scripts.
|
|
290
|
-
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
291
|
-
if (!data!.csr && isDev) {
|
|
292
|
-
console.warn(
|
|
293
|
-
`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
controller.enqueue(
|
|
297
|
-
enc.encode(
|
|
298
|
-
buildHtmlTail(
|
|
299
|
-
"",
|
|
300
|
-
"",
|
|
301
|
-
data!.pageData,
|
|
302
|
-
data!.layoutData,
|
|
303
|
-
true,
|
|
304
|
-
null,
|
|
305
|
-
false,
|
|
306
|
-
),
|
|
307
|
-
),
|
|
308
|
-
);
|
|
309
|
-
controller.close();
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const { body, head } = render(App, {
|
|
314
|
-
props: {
|
|
315
|
-
ssrMode: true,
|
|
316
|
-
ssrPageComponent: pageMod.default,
|
|
317
|
-
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
318
|
-
ssrPageData: data!.pageData,
|
|
319
|
-
ssrLayoutData: data!.layoutData,
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// Chunk 3: rendered content
|
|
324
|
-
controller.enqueue(
|
|
325
|
-
enc.encode(
|
|
326
|
-
buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr),
|
|
327
|
-
),
|
|
328
|
-
);
|
|
330
|
+
pull(controller) {
|
|
331
|
+
if (cancelled || i >= chunks.length) {
|
|
329
332
|
controller.close();
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
controller.enqueue(chunks[i++]);
|
|
337
|
+
if (i >= chunks.length) {
|
|
335
338
|
controller.close();
|
|
339
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
336
340
|
}
|
|
337
341
|
},
|
|
342
|
+
cancel() {
|
|
343
|
+
cancelled = true;
|
|
344
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
345
|
+
},
|
|
338
346
|
});
|
|
339
347
|
|
|
340
348
|
return new Response(stream, {
|
package/src/core/routeFile.ts
CHANGED
|
@@ -63,6 +63,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
63
63
|
lines.push(" pageServer: (() => Promise<any>) | null;");
|
|
64
64
|
lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
|
|
65
65
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
66
|
+
lines.push(' scope: "public" | "private";');
|
|
66
67
|
lines.push("}> = [");
|
|
67
68
|
for (const r of pages) {
|
|
68
69
|
const layoutImports = r.layouts
|
|
@@ -83,6 +84,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
83
84
|
);
|
|
84
85
|
lines.push(` layoutServers: [${layoutServerImports}],`);
|
|
85
86
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
87
|
+
lines.push(` scope: ${JSON.stringify(r.scope)},`);
|
|
86
88
|
lines.push(" },");
|
|
87
89
|
}
|
|
88
90
|
lines.push("];\n");
|
package/src/core/scanner.ts
CHANGED
|
@@ -44,6 +44,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
44
44
|
layoutChain: string[],
|
|
45
45
|
layoutServerChain: { path: string; depth: number }[],
|
|
46
46
|
inheritedTrailingSlash: TrailingSlash,
|
|
47
|
+
inheritedScope: "public" | "private",
|
|
47
48
|
) {
|
|
48
49
|
const fullDir = join(ROUTES_DIR, dir);
|
|
49
50
|
if (!existsSync(fullDir)) return;
|
|
@@ -94,6 +95,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
94
95
|
pageServer: pageServerFile,
|
|
95
96
|
layoutServers: [...currentLayoutServers],
|
|
96
97
|
trailingSlash: effectiveTs,
|
|
98
|
+
scope: inheritedScope,
|
|
97
99
|
});
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -105,6 +107,9 @@ export function scanRoutes(): RouteManifest {
|
|
|
105
107
|
const dirName = entry.name;
|
|
106
108
|
// Route groups like (public), (auth) are invisible in URLs
|
|
107
109
|
const isGroup = /^\(.*\)$/.test(dirName);
|
|
110
|
+
// `(private)` anywhere in the chain marks descendants as per-user (dedup off)
|
|
111
|
+
const childScope: "public" | "private" =
|
|
112
|
+
inheritedScope === "private" || dirName === "(private)" ? "private" : "public";
|
|
108
113
|
|
|
109
114
|
walk(
|
|
110
115
|
dir ? join(dir, dirName) : dirName,
|
|
@@ -112,11 +117,12 @@ export function scanRoutes(): RouteManifest {
|
|
|
112
117
|
currentLayouts,
|
|
113
118
|
currentLayoutServers,
|
|
114
119
|
currentTrailingSlash,
|
|
120
|
+
childScope,
|
|
115
121
|
);
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
walk("", [], [], [], "never");
|
|
125
|
+
walk("", [], [], [], "never", "public");
|
|
120
126
|
|
|
121
127
|
// Warn when a catch-all exists but no exact route covers its prefix.
|
|
122
128
|
// e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
|
package/src/core/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { CsrfConfig } from "./csrf.ts";
|
|
|
17
17
|
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
18
18
|
import type { CorsConfig } from "./cors.ts";
|
|
19
19
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
20
|
+
import { dedup, dedupKey } from "./dedup.ts";
|
|
20
21
|
import {
|
|
21
22
|
loadRouteData,
|
|
22
23
|
loadMetadata,
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
renderPageWithFormData,
|
|
26
27
|
} from "./renderer.ts";
|
|
27
28
|
import { getServerTime } from "../lib/utils.ts";
|
|
28
|
-
import { dedup, dedupKey } from "./dedup.ts";
|
|
29
29
|
|
|
30
30
|
// ─── User Hooks ──────────────────────────────────────────
|
|
31
31
|
// Load src/hooks.server.ts if present. Uses process.cwd() so
|
|
@@ -148,10 +148,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
148
148
|
}
|
|
149
149
|
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
150
150
|
event.url = routeUrl;
|
|
151
|
-
const dedupKeyStr = dedupKey(routeUrl, request);
|
|
152
151
|
try {
|
|
153
|
-
const
|
|
154
|
-
|
|
152
|
+
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
153
|
+
const runLoad = async () => {
|
|
155
154
|
const data = await loadRouteData(routeUrl, locals, request, cookies);
|
|
156
155
|
|
|
157
156
|
let metadata = null;
|
|
@@ -172,7 +171,14 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
175
|
-
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Dedup public routes by URL only. `(private)` scope routes (per-user content)
|
|
177
|
+
// skip the cache to prevent cross-user data leaks. See dedup.ts.
|
|
178
|
+
const result =
|
|
179
|
+
pageMatch?.route.scope === "private"
|
|
180
|
+
? await runLoad()
|
|
181
|
+
: await dedup(dedupKey(routeUrl), runLoad);
|
|
176
182
|
|
|
177
183
|
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
178
184
|
const cc = cookiesWereAccessed
|
|
@@ -649,8 +655,8 @@ async function shutdown() {
|
|
|
649
655
|
console.log("✅ All requests drained");
|
|
650
656
|
}
|
|
651
657
|
|
|
652
|
-
app.stop().then(() => process.exit(0));
|
|
653
|
-
setTimeout(() => process.exit(1),
|
|
658
|
+
app.stop(true).then(() => process.exit(0));
|
|
659
|
+
setTimeout(() => process.exit(1), 2_000);
|
|
654
660
|
}
|
|
655
661
|
|
|
656
662
|
process.on("SIGTERM", shutdown);
|
package/src/core/types.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface PageRoute {
|
|
|
17
17
|
layoutServers: { path: string; depth: number }[];
|
|
18
18
|
/** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
|
|
19
19
|
trailingSlash: TrailingSlash;
|
|
20
|
+
/**
|
|
21
|
+
* Dedup scope. `"public"` (default) → loader runs once for concurrent identical
|
|
22
|
+
* URLs. `"private"` → loader runs per-request (use for per-user routes).
|
|
23
|
+
* Set by placing the route under a `(private)` group folder anywhere in the chain.
|
|
24
|
+
*/
|
|
25
|
+
scope: "public" | "private";
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** An API route discovered from the file system */
|