bosia 0.5.6 → 0.5.8
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/README.md +2 -2
- package/package.json +2 -1
- package/src/core/build.ts +15 -1
- package/src/core/client/App.svelte +14 -9
- package/src/core/client/hydrate.ts +4 -1
- package/src/core/devErrorReport.ts +30 -0
- package/src/core/html.ts +4 -1
- package/src/core/plugins/inspector/bun-plugin.ts +39 -1
- package/src/core/plugins/inspector/index.ts +229 -3
- package/src/core/plugins/inspector/overlay.ts +158 -3
- package/src/core/plugins/inspector/sourcemap.ts +135 -0
- package/src/core/renderer.ts +22 -0
- package/src/core/routeTypes.ts +5 -7
- package/src/core/server.ts +16 -4
- package/src/core/svelteCompiler.ts +43 -1
- package/templates/default/README.md +13 -9
- package/templates/default/src/routes/{+page.svelte → (public)/+page.svelte} +3 -2
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +1 -1
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +3 -3
- /package/templates/default/src/routes/{about → (public)/about}/+page.server.ts +0 -0
- /package/templates/default/src/routes/{about → (public)/about}/+page.svelte +0 -0
- /package/templates/todo/src/routes/{+page.svelte → (public)/+page.svelte} +0 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
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": [
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@clack/prompts": "^1.1.0",
|
|
54
|
+
"@jridgewell/trace-mapping": "^0.3.31",
|
|
54
55
|
"@tailwindcss/cli": "^4.2.1",
|
|
55
56
|
"elysia": "^1.4.26",
|
|
56
57
|
"magic-string": "^0.30.0",
|
package/src/core/build.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { scanRoutes } from "./scanner.ts";
|
|
|
5
5
|
import { generateRoutesFile } from "./routeFile.ts";
|
|
6
6
|
import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
|
|
7
7
|
import { makeBosiaPlugin } from "./plugin.ts";
|
|
8
|
-
import { makeBosiaSvelteCompiler } from "./svelteCompiler.ts";
|
|
8
|
+
import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
|
|
9
9
|
import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
|
|
10
10
|
import { loadEnv, classifyEnvVars } from "./env.ts";
|
|
11
11
|
import { generateEnvModules } from "./envCodegen.ts";
|
|
@@ -142,6 +142,7 @@ const clientPromise = Bun.build({
|
|
|
142
142
|
splitting: true,
|
|
143
143
|
naming: { chunk: "[name]-[hash].[ext]" },
|
|
144
144
|
minify: isProduction,
|
|
145
|
+
sourcemap: isProduction ? "none" : "linked",
|
|
145
146
|
define: {
|
|
146
147
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
|
|
147
148
|
...staticDefines,
|
|
@@ -156,6 +157,7 @@ const serverPromise = Bun.build({
|
|
|
156
157
|
splitting: true,
|
|
157
158
|
naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
|
|
158
159
|
minify: isProduction,
|
|
160
|
+
sourcemap: isProduction ? "none" : "linked",
|
|
159
161
|
external: ["elysia"],
|
|
160
162
|
plugins: [serverPlugin, ...userServerBunPlugins, makeBosiaSvelteCompiler("bun")],
|
|
161
163
|
});
|
|
@@ -185,6 +187,18 @@ if (!serverResult.success) {
|
|
|
185
187
|
process.exit(1);
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
// Persist the per-file Svelte compile maps so the inspector's runtime stack
|
|
191
|
+
// resolver can chain bundle-map → svelte-map to land on original source. We
|
|
192
|
+
// cannot chain at bundle time because Bun's bundle maps reference intermediate
|
|
193
|
+
// JS positions that are mostly absent from Svelte's sparse compile map —
|
|
194
|
+
// post-build remapping nukes mappings. Instead we keep both maps separate and
|
|
195
|
+
// do a two-stage lookup with `bias` interpolation in the resolver.
|
|
196
|
+
if (!isProduction && svelteMapCache.size > 0) {
|
|
197
|
+
const entries: Record<string, unknown> = {};
|
|
198
|
+
for (const [k, v] of svelteMapCache) entries[k] = v;
|
|
199
|
+
writeFileSync(`${OUT_DIR}/svelte-maps.json`, JSON.stringify(entries));
|
|
200
|
+
}
|
|
201
|
+
|
|
188
202
|
// 6. Collect output files for dist/manifest.json
|
|
189
203
|
const jsFiles: string[] = [];
|
|
190
204
|
const cssFiles: string[] = [];
|
|
@@ -31,12 +31,19 @@
|
|
|
31
31
|
|
|
32
32
|
let PageComponent = $state<any>(ssrPageComponent);
|
|
33
33
|
let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
|
|
34
|
+
// Network protocol still ships `params` merged into pageData (see renderer.ts).
|
|
35
|
+
// Strip it at the component boundary so consumers receive `params` only via
|
|
36
|
+
// the dedicated prop, matching SvelteKit's surface.
|
|
37
|
+
const stripParams = (d: Record<string, any>) => {
|
|
38
|
+
const { params: _p, ...rest } = d;
|
|
39
|
+
return rest;
|
|
40
|
+
};
|
|
34
41
|
// In SSR mode, render directly from props (server module singletons must
|
|
35
42
|
// not hold per-request state). On the client, read/write through `appState`
|
|
36
43
|
// so `use:enhance` and other helpers can update the same cells.
|
|
37
|
-
const pageData = $derived(ssrMode ? (ssrPageData ?? {}) : appState.pageData);
|
|
44
|
+
const pageData = $derived(ssrMode ? stripParams(ssrPageData ?? {}) : appState.pageData);
|
|
38
45
|
const layoutData = $derived(ssrMode ? (ssrLayoutData ?? []) : appState.layoutData);
|
|
39
|
-
const
|
|
46
|
+
const params = $derived(ssrMode ? (ssrPageData?.params ?? {}) : appState.routeParams);
|
|
40
47
|
const formData = $derived(ssrMode ? ssrFormData : appState.form);
|
|
41
48
|
const ErrorComponent = $derived(ssrMode ? ssrErrorComponent : appState.errorComponent);
|
|
42
49
|
const errorProps = $derived(ssrMode ? ssrErrorProps : appState.errorProps);
|
|
@@ -257,9 +264,7 @@
|
|
|
257
264
|
mergedPageData = {};
|
|
258
265
|
}
|
|
259
266
|
|
|
260
|
-
|
|
261
|
-
// stale params from when the loader ran, so trust the live match.
|
|
262
|
-
appState.pageData = { ...mergedPageData, params: match.params };
|
|
267
|
+
appState.pageData = mergedPageData;
|
|
263
268
|
appState.layoutData = mergedLayoutData;
|
|
264
269
|
appState.routeParams = match.params;
|
|
265
270
|
// Successful navigation — clear any prior error state.
|
|
@@ -314,7 +319,7 @@
|
|
|
314
319
|
{:else if layoutComponents.length > 0}
|
|
315
320
|
{@render renderLayout(0, layoutComponents.length)}
|
|
316
321
|
{:else if PageComponent}
|
|
317
|
-
<PageComponent data={
|
|
322
|
+
<PageComponent data={pageData} {params} form={formData} />
|
|
318
323
|
{:else}
|
|
319
324
|
<p>Loading...</p>
|
|
320
325
|
{/if}
|
|
@@ -324,15 +329,15 @@
|
|
|
324
329
|
{@const data = layoutData[index] ?? {}}
|
|
325
330
|
|
|
326
331
|
{#if index < leafDepth - 1}
|
|
327
|
-
<Layout {data}>
|
|
332
|
+
<Layout {data} {params}>
|
|
328
333
|
{@render renderLayout(index + 1, leafDepth)}
|
|
329
334
|
</Layout>
|
|
330
335
|
{:else}
|
|
331
|
-
<Layout {data}>
|
|
336
|
+
<Layout {data} {params}>
|
|
332
337
|
{#if ErrorComponent}
|
|
333
338
|
<ErrorComponent {...errorProps ?? {}} />
|
|
334
339
|
{:else if PageComponent}
|
|
335
|
-
<PageComponent data={
|
|
340
|
+
<PageComponent data={pageData} {params} form={formData} />
|
|
336
341
|
{:else}
|
|
337
342
|
<p>Loading...</p>
|
|
338
343
|
{/if}
|
|
@@ -67,7 +67,10 @@ async function main() {
|
|
|
67
67
|
|
|
68
68
|
// Seed shared client state so `use:enhance` and other helpers
|
|
69
69
|
// start from the same values App.svelte renders during hydration.
|
|
70
|
-
|
|
70
|
+
// Network protocol still ships `params` merged into pageData; strip it
|
|
71
|
+
// so the in-memory pageData mirrors what +page.svelte receives.
|
|
72
|
+
const { params: _p, ...pageDataNoParams } = ssrPageData ?? {};
|
|
73
|
+
appState.pageData = pageDataNoParams;
|
|
71
74
|
appState.layoutData = ssrLayoutData;
|
|
72
75
|
appState.routeParams = ssrPageData?.params ?? match?.params ?? {};
|
|
73
76
|
appState.form = ssrFormData;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Dev-only bridge: framework catch sites (renderer, server) push errors into
|
|
2
|
+
// the inspector plugin's overlay via a globalThis-set function. The inspector
|
|
3
|
+
// is loaded from disk via the user's bosia.config.ts and lives in a separate
|
|
4
|
+
// module graph from the bundled server, so a direct import would not share
|
|
5
|
+
// state. The global is only set when the inspector plugin is installed; this
|
|
6
|
+
// is a safe no-op otherwise.
|
|
7
|
+
|
|
8
|
+
interface DevReportInput {
|
|
9
|
+
source?: "elysia" | "uncaught" | "rejection";
|
|
10
|
+
message: string;
|
|
11
|
+
stack?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare global {
|
|
15
|
+
// eslint-disable-next-line no-var
|
|
16
|
+
var __BOSIA_REPORT_ERROR__: ((e: DevReportInput) => void) | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function reportDevErrorFromCatch(err: unknown): void {
|
|
20
|
+
const fn = globalThis.__BOSIA_REPORT_ERROR__;
|
|
21
|
+
if (typeof fn !== "function") return;
|
|
22
|
+
const e = err as Error | undefined;
|
|
23
|
+
try {
|
|
24
|
+
fn({
|
|
25
|
+
source: "elysia",
|
|
26
|
+
message: e?.message ?? String(err),
|
|
27
|
+
stack: e?.stack,
|
|
28
|
+
});
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
package/src/core/html.ts
CHANGED
|
@@ -97,6 +97,7 @@ export function buildHtml(
|
|
|
97
97
|
nonce?: string,
|
|
98
98
|
pageDeps: any = null,
|
|
99
99
|
layoutDeps: any[] | null = null,
|
|
100
|
+
bodyEndExtras?: string[],
|
|
100
101
|
): string {
|
|
101
102
|
const cssLinks = (distManifest.css ?? [])
|
|
102
103
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
@@ -135,6 +136,8 @@ export function buildHtml(
|
|
|
135
136
|
? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
|
|
136
137
|
: "";
|
|
137
138
|
|
|
139
|
+
const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
|
|
140
|
+
|
|
138
141
|
return `<!DOCTYPE html>
|
|
139
142
|
<html lang="${safeLang(lang)}">
|
|
140
143
|
<head>
|
|
@@ -148,7 +151,7 @@ export function buildHtml(
|
|
|
148
151
|
<script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
|
|
149
152
|
</head>
|
|
150
153
|
<body>
|
|
151
|
-
<div id="app">${body}</div>${scripts}
|
|
154
|
+
<div id="app">${body}</div>${scripts}${bodyEnd}
|
|
152
155
|
</body>
|
|
153
156
|
</html>`;
|
|
154
157
|
}
|
|
@@ -2,6 +2,7 @@ import { parse, compile } from "svelte/compiler";
|
|
|
2
2
|
import MagicString from "magic-string";
|
|
3
3
|
import { basename, relative } from "node:path";
|
|
4
4
|
import type { BunPlugin } from "bun";
|
|
5
|
+
import { svelteMapCache } from "../../svelteCompiler.ts";
|
|
5
6
|
|
|
6
7
|
const VIRTUAL_NS = "bosia-inspector-css";
|
|
7
8
|
|
|
@@ -87,6 +88,21 @@ export interface InspectorBunPluginOptions {
|
|
|
87
88
|
dev: boolean;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// Svelte 5 dev compile emits named `function get()` / `function set($$value)`
|
|
92
|
+
// expressions inside `$.bind_*` calls (for nicer `$inspect` stack traces). Bun's
|
|
93
|
+
// bundler destructures `import * as $ from "svelte/internal/client"` into named
|
|
94
|
+
// imports, so `$.get(search)` becomes plain `get(search)` — which collides with
|
|
95
|
+
// the wrapping function name and recurses into itself → RangeError. Prod compile
|
|
96
|
+
// uses anonymous arrow functions and is unaffected.
|
|
97
|
+
//
|
|
98
|
+
// Rename to `$$g` / `$$s` (3 chars — length-preserving so cached svelte source
|
|
99
|
+
// map columns stay accurate). These names aren't present in svelte/internal/client.
|
|
100
|
+
function fixBindShadow(code: string): string {
|
|
101
|
+
return code
|
|
102
|
+
.replace(/\bfunction get\(\)/g, () => "function $$g()")
|
|
103
|
+
.replace(/\bfunction set\(\$\$value\)/g, () => "function $$s($$value)");
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
const fnv = (s: string): string => {
|
|
91
107
|
let h = 2166136261;
|
|
92
108
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -120,7 +136,29 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
120
136
|
cssHash: ({ css }) => `svelte-${fnv(css)}`,
|
|
121
137
|
});
|
|
122
138
|
|
|
123
|
-
|
|
139
|
+
// Store Svelte's compile-step map. Bun's bundler doesn't chain
|
|
140
|
+
// onLoad sourcemaps, so the final bundle map resolves stack frames
|
|
141
|
+
// to the post-svelte-compile JS intermediate (`$.next()` /
|
|
142
|
+
// `$.append()` calls) using the .svelte filename — line numbers
|
|
143
|
+
// land past EOF. The inspector's runtime resolver chases bundle
|
|
144
|
+
// map → this svelte map (with bias-interpolated lookup) to refine
|
|
145
|
+
// to original source. `injectLocs` only shifts column offsets
|
|
146
|
+
// inside HTML open tags, never script-block line numbers, so we
|
|
147
|
+
// accept the small column drift to skip a second map chain.
|
|
148
|
+
//
|
|
149
|
+
// Only cache for the client target — server (Bun) and client builds
|
|
150
|
+
// share `svelteMapCache` keyed by abs path, but their compile output
|
|
151
|
+
// line numbers differ. The resolver translates browser-side stack
|
|
152
|
+
// frames (delivered via SSE), which run client code.
|
|
153
|
+
if (dev && generate === "client" && result.js.map) {
|
|
154
|
+
const m =
|
|
155
|
+
typeof result.js.map === "string"
|
|
156
|
+
? JSON.parse(result.js.map)
|
|
157
|
+
: result.js.map;
|
|
158
|
+
svelteMapCache.set(args.path, m);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let js = dev ? fixBindShadow(result.js.code) : result.js.code;
|
|
124
162
|
// Skip empty CSS — multiple +page.svelte routes with no <style> would
|
|
125
163
|
// otherwise each emit an empty CSS chunk, all hashing to the same
|
|
126
164
|
// content → Bun fails with "Multiple files share the same output path".
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { Elysia } from "elysia";
|
|
2
4
|
import { createInspectorBunPlugin } from "./bun-plugin.ts";
|
|
3
5
|
import { getOverlayScript } from "./overlay.ts";
|
|
6
|
+
import { resolveFrame, resolveStack } from "./sourcemap.ts";
|
|
4
7
|
import type { BosiaPlugin } from "../../types/plugin.ts";
|
|
5
8
|
|
|
6
9
|
export interface InspectorOptions {
|
|
@@ -10,13 +13,147 @@ export interface InspectorOptions {
|
|
|
10
13
|
aiEndpoint?: string;
|
|
11
14
|
/** Endpoint path the overlay POSTs to. Defaults to `/__bosia/locate`. */
|
|
12
15
|
endpoint?: string;
|
|
16
|
+
/** Capture client + server runtime errors and surface them in the overlay. Default `true`. */
|
|
17
|
+
errorsEnabled?: boolean;
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
interface ServerError {
|
|
21
|
+
id: string;
|
|
22
|
+
ts: number;
|
|
23
|
+
source: "elysia" | "uncaught" | "rejection";
|
|
24
|
+
message: string;
|
|
25
|
+
stack?: string;
|
|
26
|
+
file?: string;
|
|
27
|
+
line?: number;
|
|
28
|
+
col?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RUNTIME_ERROR_PREFIX = "[runtime error]";
|
|
32
|
+
|
|
15
33
|
function buildEditorArgs(editor: string, file: string, line: number, col: number): string[] {
|
|
16
34
|
if (editor === "zed") return [`${file}:${line}:${col}`];
|
|
17
35
|
return ["-g", `${file}:${line}:${col}`];
|
|
18
36
|
}
|
|
19
37
|
|
|
38
|
+
// Parse the top frame out of a stack trace string. Best-effort.
|
|
39
|
+
function parseTopFrame(
|
|
40
|
+
stack: string | undefined,
|
|
41
|
+
): { file: string; line: number; col: number } | null {
|
|
42
|
+
if (!stack) return null;
|
|
43
|
+
const m =
|
|
44
|
+
/\((https?:\/\/[^)]+|\/[^)]+):(\d+):(\d+)\)/.exec(stack) ||
|
|
45
|
+
/at\s+(\S+):(\d+):(\d+)/.exec(stack) ||
|
|
46
|
+
/@(\S+):(\d+):(\d+)/.exec(stack);
|
|
47
|
+
return m ? { file: m[1], line: Number(m[2]), col: Number(m[3]) } : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Module-scoped state for the error feature. Initialised lazily inside the
|
|
51
|
+
// plugin so importing this module doesn't attach process-wide listeners.
|
|
52
|
+
let processListenersInstalled = false;
|
|
53
|
+
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
54
|
+
const dedupMap = new Map<string, { lastTs: number }>();
|
|
55
|
+
const DEDUP_WINDOW_MS = 500;
|
|
56
|
+
// Small bounded replay buffer — errors that fire during a failing render
|
|
57
|
+
// happen *before* the 500 page's overlay can connect to SSE. On connect we
|
|
58
|
+
// flush recent errors so the badge isn't empty.
|
|
59
|
+
const REPLAY_LIMIT = 50;
|
|
60
|
+
const REPLAY_TTL_MS = 30_000;
|
|
61
|
+
const replayBuffer: ServerError[] = [];
|
|
62
|
+
|
|
63
|
+
function encode(line: string): Uint8Array {
|
|
64
|
+
return new TextEncoder().encode(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function broadcastError(err: ServerError) {
|
|
68
|
+
const payload = encode(`event: bosia-error\ndata: ${JSON.stringify(err)}\n\n`);
|
|
69
|
+
for (const ctrl of sseClients) {
|
|
70
|
+
try {
|
|
71
|
+
ctrl.enqueue(payload);
|
|
72
|
+
} catch {
|
|
73
|
+
sseClients.delete(ctrl);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pushServerError(input: Omit<ServerError, "id" | "ts">) {
|
|
79
|
+
const firstFrame = input.stack ? (input.stack.split("\n").find((l) => l.trim()) ?? "") : "";
|
|
80
|
+
const key = `${input.source}:${input.message}:${firstFrame}`;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const prev = dedupMap.get(key);
|
|
83
|
+
if (prev && now - prev.lastTs < DEDUP_WINDOW_MS) {
|
|
84
|
+
prev.lastTs = now;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
dedupMap.set(key, { lastTs: now });
|
|
88
|
+
|
|
89
|
+
const top = parseTopFrame(input.stack);
|
|
90
|
+
const err: ServerError = {
|
|
91
|
+
id: randomUUID(),
|
|
92
|
+
ts: now,
|
|
93
|
+
source: input.source,
|
|
94
|
+
message: input.message,
|
|
95
|
+
stack: input.stack,
|
|
96
|
+
file: input.file ?? top?.file,
|
|
97
|
+
line: input.line ?? top?.line,
|
|
98
|
+
col: input.col ?? top?.col,
|
|
99
|
+
};
|
|
100
|
+
replayBuffer.push(err);
|
|
101
|
+
if (replayBuffer.length > REPLAY_LIMIT) replayBuffer.shift();
|
|
102
|
+
broadcastError(err);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function flushReplay(ctrl: ReadableStreamDefaultController<Uint8Array>) {
|
|
106
|
+
const cutoff = Date.now() - REPLAY_TTL_MS;
|
|
107
|
+
for (const err of replayBuffer) {
|
|
108
|
+
if (err.ts < cutoff) continue;
|
|
109
|
+
try {
|
|
110
|
+
ctrl.enqueue(encode(`event: bosia-error\ndata: ${JSON.stringify(err)}\n\n`));
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function installProcessListeners() {
|
|
118
|
+
if (processListenersInstalled) return;
|
|
119
|
+
processListenersInstalled = true;
|
|
120
|
+
|
|
121
|
+
process.on("uncaughtException", (err: Error) => {
|
|
122
|
+
pushServerError({
|
|
123
|
+
source: "uncaught",
|
|
124
|
+
message: err?.message ?? String(err),
|
|
125
|
+
stack: err?.stack,
|
|
126
|
+
});
|
|
127
|
+
// Rethrow so the dev runner's existing crash-recovery still triggers.
|
|
128
|
+
throw err;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
process.on("unhandledRejection", (reason: unknown) => {
|
|
132
|
+
const e = reason as Error | undefined;
|
|
133
|
+
pushServerError({
|
|
134
|
+
source: "rejection",
|
|
135
|
+
message: e?.message ?? String(reason),
|
|
136
|
+
stack: e?.stack,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Expose the push function via globalThis so framework catch sites — which
|
|
142
|
+
// live in a separately-bundled module graph from this disk-loaded plugin —
|
|
143
|
+
// can notify the inspector without an import dependency. Renderer catches
|
|
144
|
+
// (page load, layout load, SSR load) swallow errors and return a rendered
|
|
145
|
+
// error page, so they never reach Elysia `.onError()` on their own. The
|
|
146
|
+
// global is typed in core/devErrorReport.ts.
|
|
147
|
+
function installGlobalReporter() {
|
|
148
|
+
globalThis.__BOSIA_REPORT_ERROR__ = (e) => {
|
|
149
|
+
pushServerError({
|
|
150
|
+
source: e.source ?? "elysia",
|
|
151
|
+
message: e.message,
|
|
152
|
+
stack: e.stack,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
20
157
|
/**
|
|
21
158
|
* Inspector plugin — alt+click an element in the running dev page to jump
|
|
22
159
|
* to its source in your editor, or open a comment form that hands off to an
|
|
@@ -27,6 +164,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
27
164
|
const editor = options.editor ?? "code";
|
|
28
165
|
const endpoint = options.endpoint ?? "/__bosia/locate";
|
|
29
166
|
const aiEndpoint = options.aiEndpoint;
|
|
167
|
+
const errorsEnabled = options.errorsEnabled !== false;
|
|
30
168
|
|
|
31
169
|
return {
|
|
32
170
|
name: "inspector",
|
|
@@ -39,7 +177,10 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
39
177
|
|
|
40
178
|
backend: {
|
|
41
179
|
before(app) {
|
|
42
|
-
|
|
180
|
+
// Cast to the base Elysia type — chaining .post/.get/.onError narrows
|
|
181
|
+
// the generic so the plugin return type drifts unless we widen back.
|
|
182
|
+
let chained: Elysia = app as unknown as Elysia;
|
|
183
|
+
chained = chained.post(endpoint, async ({ body }: { body: unknown }) => {
|
|
43
184
|
const data = (body ?? {}) as {
|
|
44
185
|
file?: string;
|
|
45
186
|
line?: number;
|
|
@@ -61,6 +202,22 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
61
202
|
|
|
62
203
|
const comment = typeof data.comment === "string" ? data.comment.trim() : "";
|
|
63
204
|
if (comment && aiEndpoint) {
|
|
205
|
+
// Runtime-error comments arrive with compiled stack frames. Resolve
|
|
206
|
+
// them lazily here so the AI receives source paths it can actually
|
|
207
|
+
// edit. Plain alt-click comments skip resolution.
|
|
208
|
+
let finalFile = file;
|
|
209
|
+
let finalLine = line;
|
|
210
|
+
let finalCol = col;
|
|
211
|
+
let finalComment = comment;
|
|
212
|
+
if (comment.startsWith(RUNTIME_ERROR_PREFIX)) {
|
|
213
|
+
const resolved = resolveFrame(file, line, col);
|
|
214
|
+
if (resolved) {
|
|
215
|
+
finalFile = resolved.file;
|
|
216
|
+
finalLine = resolved.line;
|
|
217
|
+
finalCol = resolved.col;
|
|
218
|
+
}
|
|
219
|
+
finalComment = resolveStack(comment);
|
|
220
|
+
}
|
|
64
221
|
try {
|
|
65
222
|
let origin: string;
|
|
66
223
|
try {
|
|
@@ -74,7 +231,12 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
74
231
|
"content-type": "application/json",
|
|
75
232
|
origin,
|
|
76
233
|
},
|
|
77
|
-
body: JSON.stringify({
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
file: finalFile,
|
|
236
|
+
line: finalLine,
|
|
237
|
+
col: finalCol,
|
|
238
|
+
comment: finalComment,
|
|
239
|
+
}),
|
|
78
240
|
});
|
|
79
241
|
return { ok: true, mode: "ai" as const };
|
|
80
242
|
} catch (err) {
|
|
@@ -110,11 +272,75 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
110
272
|
}
|
|
111
273
|
return { ok: true, mode: "editor" as const };
|
|
112
274
|
});
|
|
275
|
+
|
|
276
|
+
if (errorsEnabled) {
|
|
277
|
+
installProcessListeners();
|
|
278
|
+
installGlobalReporter();
|
|
279
|
+
|
|
280
|
+
// Elysia onError — runs because server.ts registers plugin
|
|
281
|
+
// backend.before() ahead of the base .onError() responder.
|
|
282
|
+
chained = chained.onError(({ error }) => {
|
|
283
|
+
const e = error as Error;
|
|
284
|
+
pushServerError({
|
|
285
|
+
source: "elysia",
|
|
286
|
+
message: e?.message ?? String(error),
|
|
287
|
+
stack: e?.stack,
|
|
288
|
+
});
|
|
289
|
+
// Fall through to the base handler.
|
|
290
|
+
return undefined;
|
|
291
|
+
}) as unknown as Elysia;
|
|
292
|
+
|
|
293
|
+
// Live SSE stream. New clients also get a flush of the bounded
|
|
294
|
+
// replay buffer so errors that fired during a failing render
|
|
295
|
+
// (before the 500 page's overlay could subscribe) are visible.
|
|
296
|
+
chained = chained.get(
|
|
297
|
+
"/__bosia/errors",
|
|
298
|
+
({ request }: { request: Request }) => {
|
|
299
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
300
|
+
start(ctrl) {
|
|
301
|
+
sseClients.add(ctrl);
|
|
302
|
+
try {
|
|
303
|
+
ctrl.enqueue(encode(":ok\n\n"));
|
|
304
|
+
} catch {
|
|
305
|
+
sseClients.delete(ctrl);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
flushReplay(ctrl);
|
|
309
|
+
const ping = setInterval(() => {
|
|
310
|
+
try {
|
|
311
|
+
ctrl.enqueue(encode(":ping\n\n"));
|
|
312
|
+
} catch {
|
|
313
|
+
clearInterval(ping);
|
|
314
|
+
sseClients.delete(ctrl);
|
|
315
|
+
}
|
|
316
|
+
}, 25_000);
|
|
317
|
+
|
|
318
|
+
request.signal.addEventListener("abort", () => {
|
|
319
|
+
clearInterval(ping);
|
|
320
|
+
sseClients.delete(ctrl);
|
|
321
|
+
try {
|
|
322
|
+
ctrl.close();
|
|
323
|
+
} catch {}
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
return new Response(stream, {
|
|
328
|
+
headers: {
|
|
329
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
330
|
+
"Cache-Control": "no-cache",
|
|
331
|
+
Connection: "keep-alive",
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
) as unknown as Elysia;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return chained;
|
|
113
339
|
},
|
|
114
340
|
},
|
|
115
341
|
|
|
116
342
|
render: {
|
|
117
|
-
bodyEnd: () => getOverlayScript({ aiEndpoint, endpoint }),
|
|
343
|
+
bodyEnd: () => getOverlayScript({ aiEndpoint, endpoint, errorsEnabled }),
|
|
118
344
|
},
|
|
119
345
|
};
|
|
120
346
|
}
|
|
@@ -3,6 +3,7 @@ import { safeJsonStringify } from "../../html.ts";
|
|
|
3
3
|
export interface OverlayConfig {
|
|
4
4
|
aiEndpoint?: string;
|
|
5
5
|
endpoint: string;
|
|
6
|
+
errorsEnabled?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function getOverlayScript(config: OverlayConfig): string {
|
|
@@ -16,6 +17,7 @@ const OVERLAY_IIFE = `(function(){
|
|
|
16
17
|
var CFG=window.__BOSIA_INSPECTOR__||{};
|
|
17
18
|
var EP=CFG.endpoint||"/__bosia/locate";
|
|
18
19
|
var AI=CFG.aiEndpoint||null;
|
|
20
|
+
var ERR_ENABLED=CFG.errorsEnabled!==false;
|
|
19
21
|
var altDown=false,outline=null,tip=null,form=null;
|
|
20
22
|
|
|
21
23
|
function ensureOutline(){
|
|
@@ -50,11 +52,11 @@ function toast(msg,err){
|
|
|
50
52
|
setTimeout(function(){t.style.opacity="0";setTimeout(function(){t.remove()},200)},2200);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function send(payload,onOk){
|
|
55
|
+
function send(payload,onOk,onErr){
|
|
54
56
|
fetch(EP,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(payload)})
|
|
55
57
|
.then(function(r){return r.json().catch(function(){return{}})})
|
|
56
|
-
.then(function(j){if(j&&j.ok){onOk&&onOk(j)}else{toast("Inspector: request failed",true)}})
|
|
57
|
-
.catch(function(){toast("Inspector: network error",true)});
|
|
58
|
+
.then(function(j){if(j&&j.ok){onOk&&onOk(j)}else{toast("Inspector: request failed",true);onErr&&onErr()}})
|
|
59
|
+
.catch(function(){toast("Inspector: network error",true);onErr&&onErr()});
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
function closeForm(){if(form){form.remove();form=null}}
|
|
@@ -113,4 +115,157 @@ window.addEventListener("click",function(e){
|
|
|
113
115
|
if(AI)openForm(loc,el);
|
|
114
116
|
else send(loc,function(){toast("opened "+loc.file+":"+loc.line)});
|
|
115
117
|
},true);
|
|
118
|
+
|
|
119
|
+
// ─── Error capture + badge UI ─────────────────────────────────
|
|
120
|
+
if(ERR_ENABLED){
|
|
121
|
+
var errors=[];
|
|
122
|
+
var lastInteraction=null;
|
|
123
|
+
var badge=null,panel=null,seenIds={};
|
|
124
|
+
|
|
125
|
+
function uid(){return "c-"+Math.random().toString(36).slice(2)+Date.now().toString(36)}
|
|
126
|
+
function escapeHtml(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">","\\"":""","'":"'"}[c]})}
|
|
127
|
+
|
|
128
|
+
function parseTopFrame(stack){
|
|
129
|
+
if(!stack)return null;
|
|
130
|
+
var m=/\\((https?:\\/\\/[^)]+|\\/[^)]+):(\\d+):(\\d+)\\)/.exec(stack)
|
|
131
|
+
||/at\\s+(\\S+):(\\d+):(\\d+)/.exec(stack)
|
|
132
|
+
||/@(\\S+):(\\d+):(\\d+)/.exec(stack);
|
|
133
|
+
return m?{file:m[1],line:+m[2],col:+m[3]}:null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function trackInteraction(e){
|
|
137
|
+
var t=e.target;
|
|
138
|
+
if(!t||!t.closest)return;
|
|
139
|
+
var el=t.closest("[data-bosia-loc]");
|
|
140
|
+
if(el)lastInteraction=el.getAttribute("data-bosia-loc");
|
|
141
|
+
}
|
|
142
|
+
window.addEventListener("mousedown",trackInteraction,true);
|
|
143
|
+
window.addEventListener("keydown",trackInteraction,true);
|
|
144
|
+
|
|
145
|
+
function ensureBadge(){
|
|
146
|
+
if(badge)return;
|
|
147
|
+
badge=document.createElement("div");
|
|
148
|
+
badge.style.cssText="position:fixed;bottom:16px;right:16px;background:#dc2626;color:#fff;font:600 12px/1 ui-sans-serif,system-ui,sans-serif;padding:8px 12px;border-radius:999px;box-shadow:0 4px 12px rgba(0,0,0,.2);cursor:pointer;z-index:2147483646;display:none;user-select:none";
|
|
149
|
+
badge.addEventListener("click",togglePanel);
|
|
150
|
+
document.body.appendChild(badge);
|
|
151
|
+
}
|
|
152
|
+
function renderBadge(){
|
|
153
|
+
ensureBadge();
|
|
154
|
+
if(!errors.length){badge.style.display="none";if(panel){panel.remove();panel=null}return}
|
|
155
|
+
badge.style.display="block";
|
|
156
|
+
badge.textContent="● "+errors.length+" error"+(errors.length===1?"":"s");
|
|
157
|
+
if(panel)renderPanel();
|
|
158
|
+
}
|
|
159
|
+
function togglePanel(){
|
|
160
|
+
if(panel){panel.remove();panel=null;return}
|
|
161
|
+
renderPanel();
|
|
162
|
+
}
|
|
163
|
+
function renderPanel(){
|
|
164
|
+
if(panel)panel.remove();
|
|
165
|
+
panel=document.createElement("div");
|
|
166
|
+
panel.style.cssText="position:fixed;bottom:56px;right:16px;width:420px;max-height:60vh;overflow:auto;background:#fff;color:#111;border:1px solid #e4e4e7;border-radius:8px;box-shadow:0 12px 32px rgba(0,0,0,.18);font:13px ui-sans-serif,system-ui,sans-serif;z-index:2147483647";
|
|
167
|
+
var head='<div style="padding:8px 12px;border-bottom:1px solid #e4e4e7;display:flex;justify-content:space-between;align-items:center"><strong>Runtime errors</strong><button data-clear style="border:0;background:transparent;color:#71717a;font:12px ui-sans-serif,system-ui,sans-serif;cursor:pointer">Clear all</button></div>';
|
|
168
|
+
var rows="";
|
|
169
|
+
for(var i=0;i<errors.length;i++){
|
|
170
|
+
var er=errors[i];
|
|
171
|
+
var locStr=er.file?(er.file+(er.line?":"+er.line:"")):er.source;
|
|
172
|
+
rows+='<div data-row="'+er.id+'" style="padding:10px 12px;border-bottom:1px solid #f4f4f5">'
|
|
173
|
+
+'<div style="font:11px ui-monospace,monospace;color:#71717a;margin-bottom:4px">['+escapeHtml(er.source)+'] '+escapeHtml(locStr)+'</div>'
|
|
174
|
+
+'<div style="margin-bottom:6px;font-weight:500;word-break:break-word">'+escapeHtml(er.message)+'</div>'
|
|
175
|
+
+(er.stack?'<details><summary style="cursor:pointer;color:#71717a;font-size:11px">Stack</summary><pre style="margin:6px 0 0;padding:8px;background:#f4f4f5;border-radius:4px;font:11px/1.4 ui-monospace,monospace;white-space:pre-wrap;overflow:auto;max-height:200px">'+escapeHtml(er.stack)+'</pre></details>':"")
|
|
176
|
+
+'<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">'
|
|
177
|
+
+(AI?'<button data-send="'+er.id+'" style="padding:4px 10px;border:0;background:#111;color:#fff;border-radius:4px;cursor:pointer;font-size:12px">Send to AI</button>':"")
|
|
178
|
+
+'<button data-dismiss="'+er.id+'" style="padding:4px 10px;border:1px solid #e4e4e7;background:#fff;border-radius:4px;cursor:pointer;font-size:12px">Dismiss</button>'
|
|
179
|
+
+'</div>'
|
|
180
|
+
+'</div>';
|
|
181
|
+
}
|
|
182
|
+
panel.innerHTML=head+(rows||'<div style="padding:16px;color:#71717a">No errors</div>');
|
|
183
|
+
document.body.appendChild(panel);
|
|
184
|
+
panel.querySelector("[data-clear]").addEventListener("click",function(){errors=[];seenIds={};renderBadge()});
|
|
185
|
+
var sendBtns=panel.querySelectorAll("[data-send]");
|
|
186
|
+
for(var s=0;s<sendBtns.length;s++)sendBtns[s].addEventListener("click",function(ev){
|
|
187
|
+
var id=ev.currentTarget.getAttribute("data-send");
|
|
188
|
+
sendError(id);
|
|
189
|
+
});
|
|
190
|
+
var dis=panel.querySelectorAll("[data-dismiss]");
|
|
191
|
+
for(var d=0;d<dis.length;d++)dis[d].addEventListener("click",function(ev){
|
|
192
|
+
var id=ev.currentTarget.getAttribute("data-dismiss");
|
|
193
|
+
removeError(id);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function removeError(id){
|
|
198
|
+
for(var i=0;i<errors.length;i++)if(errors[i].id===id){errors.splice(i,1);break}
|
|
199
|
+
delete seenIds[id];
|
|
200
|
+
renderBadge();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sendError(id){
|
|
204
|
+
var er=null;
|
|
205
|
+
for(var i=0;i<errors.length;i++)if(errors[i].id===id){er=errors[i];break}
|
|
206
|
+
if(!er)return;
|
|
207
|
+
var top=parseTopFrame(er.stack);
|
|
208
|
+
var file=er.file||(top&&top.file)||"runtime";
|
|
209
|
+
var line=er.line||(top&&top.line)||1;
|
|
210
|
+
var col=er.col||(top&&top.col)||1;
|
|
211
|
+
var comment="[runtime error] "+er.message+"\\n\\n"+(er.stack||"(no stack)");
|
|
212
|
+
if(er.lastInteraction)comment+="\\n\\nLast user interaction: "+er.lastInteraction;
|
|
213
|
+
send({file:file,line:line,col:col,comment:comment},function(){
|
|
214
|
+
toast("sent to AI");
|
|
215
|
+
removeError(id);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pushError(e){
|
|
220
|
+
var id=e.id||uid();
|
|
221
|
+
if(seenIds[id])return;
|
|
222
|
+
seenIds[id]=true;
|
|
223
|
+
var entry={
|
|
224
|
+
id:id,
|
|
225
|
+
ts:e.ts||Date.now(),
|
|
226
|
+
source:e.source||"unknown",
|
|
227
|
+
message:e.message||"(no message)",
|
|
228
|
+
stack:e.stack,
|
|
229
|
+
file:e.file,line:e.line,col:e.col,
|
|
230
|
+
lastInteraction:lastInteraction
|
|
231
|
+
};
|
|
232
|
+
errors.push(entry);
|
|
233
|
+
renderBadge();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
window.addEventListener("error",function(ev){
|
|
237
|
+
// ev.filename/lineno/colno point at the *dispatch site* — for delegated
|
|
238
|
+
// events that's Svelte's internal events.js, not the user's handler. Prefer
|
|
239
|
+
// the top frame of ev.error.stack so the resolved source path lands in
|
|
240
|
+
// user code; fall back to event coords only when no stack is available.
|
|
241
|
+
var stack=ev.error&&ev.error.stack;
|
|
242
|
+
var top=parseTopFrame(stack);
|
|
243
|
+
pushError({
|
|
244
|
+
source:"window",
|
|
245
|
+
message:ev.message||"Unhandled error",
|
|
246
|
+
stack:stack,
|
|
247
|
+
file:(top&&top.file)||ev.filename,
|
|
248
|
+
line:(top&&top.line)||ev.lineno,
|
|
249
|
+
col:(top&&top.col)||ev.colno
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
window.addEventListener("unhandledrejection",function(ev){
|
|
253
|
+
var reason=ev.reason;
|
|
254
|
+
var stack=reason&&reason.stack;
|
|
255
|
+
var loc=parseTopFrame(stack);
|
|
256
|
+
pushError({
|
|
257
|
+
source:"rejection",
|
|
258
|
+
message:String(reason&&reason.message||reason||"Unhandled rejection"),
|
|
259
|
+
stack:stack,
|
|
260
|
+
file:loc&&loc.file,line:loc&&loc.line,col:loc&&loc.col
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
try{
|
|
265
|
+
var es=new EventSource("/__bosia/errors");
|
|
266
|
+
es.addEventListener("bosia-error",function(ev){
|
|
267
|
+
try{pushError(JSON.parse(ev.data))}catch(_){}
|
|
268
|
+
});
|
|
269
|
+
}catch(_){/* EventSource not available — ignore */}
|
|
270
|
+
}
|
|
116
271
|
})();`;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { TraceMap, originalPositionFor, GREATEST_LOWER_BOUND } from "@jridgewell/trace-mapping";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve as pathResolve } from "node:path";
|
|
4
|
+
import { OUT_DIR } from "../../paths.ts";
|
|
5
|
+
|
|
6
|
+
const cache = new Map<string, TraceMap | null>();
|
|
7
|
+
|
|
8
|
+
// Per-`.svelte` (absolute path) compile maps written by the build step. The
|
|
9
|
+
// bundle map only resolves a stack frame to the post-svelte-compile JS
|
|
10
|
+
// position labeled with the .svelte filename; a second lookup against this
|
|
11
|
+
// map (with `bias: GREATEST_LOWER_BOUND` to interpolate sparse mappings)
|
|
12
|
+
// translates that intermediate position to original source line/col.
|
|
13
|
+
let svelteMaps: Map<string, TraceMap> | null = null;
|
|
14
|
+
let svelteMapsLoaded = false;
|
|
15
|
+
|
|
16
|
+
function loadSvelteMaps(): Map<string, TraceMap> | null {
|
|
17
|
+
if (svelteMapsLoaded) return svelteMaps;
|
|
18
|
+
svelteMapsLoaded = true;
|
|
19
|
+
try {
|
|
20
|
+
const p = pathResolve(process.cwd(), OUT_DIR, "svelte-maps.json");
|
|
21
|
+
if (!existsSync(p)) return null;
|
|
22
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
|
|
23
|
+
svelteMaps = new Map();
|
|
24
|
+
for (const [absPath, m] of Object.entries(raw)) {
|
|
25
|
+
try {
|
|
26
|
+
svelteMaps.set(absPath, new TraceMap(m as never));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
return svelteMaps;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadMap(mapPath: string): TraceMap | null {
|
|
36
|
+
if (cache.has(mapPath)) return cache.get(mapPath)!;
|
|
37
|
+
try {
|
|
38
|
+
if (!existsSync(mapPath)) {
|
|
39
|
+
cache.set(mapPath, null);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const tm = new TraceMap(readFileSync(mapPath, "utf8"));
|
|
43
|
+
cache.set(mapPath, tm);
|
|
44
|
+
return tm;
|
|
45
|
+
} catch {
|
|
46
|
+
cache.set(mapPath, null);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// URL namespace stays at `/dist/...` but the on-disk location is `OUT_DIR`
|
|
52
|
+
// (e.g. `.bosia/dev` in dev). v0.5.5 decoupled these; the resolver has to
|
|
53
|
+
// rewrite the URL prefix back to the real filesystem prefix.
|
|
54
|
+
function mapPathFor(file: string): string | null {
|
|
55
|
+
let fsPath: string;
|
|
56
|
+
if (/^https?:\/\//.test(file)) {
|
|
57
|
+
let pathname: string;
|
|
58
|
+
try {
|
|
59
|
+
pathname = new URL(file).pathname;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const relFromCwd = pathname.startsWith("/dist/")
|
|
64
|
+
? OUT_DIR + pathname.slice("/dist".length)
|
|
65
|
+
: "." + pathname;
|
|
66
|
+
fsPath = pathResolve(process.cwd(), relFromCwd);
|
|
67
|
+
} else if (file.startsWith("/")) {
|
|
68
|
+
fsPath = file;
|
|
69
|
+
} else {
|
|
70
|
+
fsPath = pathResolve(process.cwd(), file);
|
|
71
|
+
}
|
|
72
|
+
return fsPath + ".map";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveFrame(
|
|
76
|
+
file: string,
|
|
77
|
+
line: number,
|
|
78
|
+
col: number,
|
|
79
|
+
): { file: string; line: number; col: number } | null {
|
|
80
|
+
const mp = mapPathFor(file);
|
|
81
|
+
if (!mp) return null;
|
|
82
|
+
const tm = loadMap(mp);
|
|
83
|
+
if (!tm) return null;
|
|
84
|
+
const pos = originalPositionFor(tm, { line, column: col });
|
|
85
|
+
if (!pos.source || pos.line == null) return null;
|
|
86
|
+
const abs = pathResolve(dirname(mp), pos.source);
|
|
87
|
+
|
|
88
|
+
// Bundle map points at the post-svelte-compile JS position labeled with the
|
|
89
|
+
// .svelte filename. Refine by chasing through the cached svelte compile map
|
|
90
|
+
// to the real source position. Svelte's map is sparse — a given line may
|
|
91
|
+
// only carry mappings starting at some column. Try the exact column first,
|
|
92
|
+
// then fall back to the rightmost mapping on the same line, so we never lose
|
|
93
|
+
// a frame just because the bundle's reported column lands in a gap.
|
|
94
|
+
if (abs.endsWith(".svelte") || abs.endsWith(".svelte.ts") || abs.endsWith(".svelte.js")) {
|
|
95
|
+
const maps = loadSvelteMaps();
|
|
96
|
+
const svelteMap = maps?.get(abs);
|
|
97
|
+
if (svelteMap) {
|
|
98
|
+
let refined = originalPositionFor(svelteMap, {
|
|
99
|
+
line: pos.line,
|
|
100
|
+
column: pos.column ?? 0,
|
|
101
|
+
bias: GREATEST_LOWER_BOUND,
|
|
102
|
+
});
|
|
103
|
+
if (!refined.source) {
|
|
104
|
+
refined = originalPositionFor(svelteMap, {
|
|
105
|
+
line: pos.line,
|
|
106
|
+
column: Number.MAX_SAFE_INTEGER,
|
|
107
|
+
bias: GREATEST_LOWER_BOUND,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (refined.source && refined.line != null) {
|
|
111
|
+
const refinedAbs = pathResolve(dirname(abs), refined.source);
|
|
112
|
+
const rel = refinedAbs.startsWith(process.cwd() + "/")
|
|
113
|
+
? refinedAbs.slice(process.cwd().length + 1)
|
|
114
|
+
: refinedAbs;
|
|
115
|
+
return { file: rel, line: refined.line, col: refined.column ?? 1 };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rel = abs.startsWith(process.cwd() + "/") ? abs.slice(process.cwd().length + 1) : abs;
|
|
121
|
+
return { file: rel, line: pos.line, col: pos.column ?? 1 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Rewrite frames in stack strings: "(url:L:C)", "at url:L:C", "@url:L:C".
|
|
125
|
+
// Lazy match on the file body so URLs with ports (`http://host:9000/...`) keep
|
|
126
|
+
// the port as part of the file rather than being chopped at the first `:`.
|
|
127
|
+
export function resolveStack(stack: string): string {
|
|
128
|
+
return stack.replace(
|
|
129
|
+
/(\(|at\s+|@)((?:https?:\/\/|\/)[^\s)]+?):(\d+):(\d+)(\)?)/g,
|
|
130
|
+
(_m, lead, file, l, c, tail) => {
|
|
131
|
+
const r = resolveFrame(file, Number(l), Number(c));
|
|
132
|
+
return r ? `${lead}${r.file}:${r.line}:${r.col}${tail}` : _m;
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./html.ts";
|
|
19
19
|
import type { Metadata } from "./hooks.ts";
|
|
20
20
|
import { loadPlugins } from "./config.ts";
|
|
21
|
+
import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
21
22
|
import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
|
|
22
23
|
|
|
23
24
|
// Plugins are loaded once per process at module init via top-level await elsewhere
|
|
@@ -362,6 +363,7 @@ export async function loadRouteData(
|
|
|
362
363
|
}
|
|
363
364
|
if (isDev) console.error("Layout server load error:", err);
|
|
364
365
|
else console.error("Layout server load error:", (err as Error).message ?? err);
|
|
366
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
365
367
|
const wrapped = new HttpError(500, "Internal Server Error");
|
|
366
368
|
stampErrorContext(
|
|
367
369
|
wrapped,
|
|
@@ -424,6 +426,7 @@ export async function loadRouteData(
|
|
|
424
426
|
}
|
|
425
427
|
if (isDev) console.error("Page server load error:", err);
|
|
426
428
|
else console.error("Page server load error:", (err as Error).message ?? err);
|
|
429
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
427
430
|
const wrapped = new HttpError(500, "Internal Server Error");
|
|
428
431
|
stampErrorContext(
|
|
429
432
|
wrapped,
|
|
@@ -552,6 +555,7 @@ export async function renderSSRStream(
|
|
|
552
555
|
}
|
|
553
556
|
if (isDev) console.error("SSR load error:", err);
|
|
554
557
|
else console.error("SSR load error:", (err as Error).message ?? err);
|
|
558
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
555
559
|
return renderErrorPage(
|
|
556
560
|
500,
|
|
557
561
|
"Internal Server Error",
|
|
@@ -828,6 +832,18 @@ export async function renderErrorPage(
|
|
|
828
832
|
// Strip the nonce from emitted scripts when CSP is off — the attribute
|
|
829
833
|
// is dead bytes without a matching policy header.
|
|
830
834
|
if (!CSP_ENABLED) nonce = undefined;
|
|
835
|
+
|
|
836
|
+
// Inspector overlay and other plugin bodyEnd fragments must be injected
|
|
837
|
+
// on error pages too — otherwise SSE never connects and runtime errors
|
|
838
|
+
// from the failing render are invisible in the UI.
|
|
839
|
+
const renderCtx: RenderContext = {
|
|
840
|
+
request: req,
|
|
841
|
+
url,
|
|
842
|
+
route: route ? { pattern: route.pattern } : { pattern: "" },
|
|
843
|
+
metadata: null,
|
|
844
|
+
};
|
|
845
|
+
const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
|
|
846
|
+
|
|
831
847
|
// 1. Nested boundary
|
|
832
848
|
if (route && errorDepth !== undefined && route.errorPages?.length) {
|
|
833
849
|
const origin = errorOrigin ?? "page";
|
|
@@ -868,6 +884,9 @@ export async function renderErrorPage(
|
|
|
868
884
|
undefined,
|
|
869
885
|
true,
|
|
870
886
|
nonce,
|
|
887
|
+
null,
|
|
888
|
+
null,
|
|
889
|
+
bodyEndExtras,
|
|
871
890
|
);
|
|
872
891
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
873
892
|
} catch (err) {
|
|
@@ -902,6 +921,9 @@ export async function renderErrorPage(
|
|
|
902
921
|
undefined,
|
|
903
922
|
true,
|
|
904
923
|
nonce,
|
|
924
|
+
null,
|
|
925
|
+
null,
|
|
926
|
+
bodyEndExtras,
|
|
905
927
|
);
|
|
906
928
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
907
929
|
} catch (err) {
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -96,18 +96,16 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
96
96
|
`export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
|
|
97
97
|
);
|
|
98
98
|
lines.push(`export type Action = (event: _RequestEvent) => any;`);
|
|
99
|
-
lines.push(
|
|
100
|
-
`export type PageData = Awaited<ReturnType<typeof _pageLoad>> & { params: Params };`,
|
|
101
|
-
);
|
|
99
|
+
lines.push(`export type PageData = Awaited<ReturnType<typeof _pageLoad>>;`);
|
|
102
100
|
} else {
|
|
103
101
|
lines.push(``);
|
|
104
102
|
lines.push(
|
|
105
103
|
`export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
|
|
106
104
|
);
|
|
107
105
|
lines.push(`export type Action = (event: _RequestEvent) => any;`);
|
|
108
|
-
lines.push(`export type PageData = {
|
|
106
|
+
lines.push(`export type PageData = {};`);
|
|
109
107
|
}
|
|
110
|
-
lines.push(`export type PageProps = { data: PageData };`);
|
|
108
|
+
lines.push(`export type PageProps = { data: PageData; params: Params };`);
|
|
111
109
|
|
|
112
110
|
if (info.hasErrorPage) {
|
|
113
111
|
lines.push(``);
|
|
@@ -135,10 +133,10 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
135
133
|
if (info.layoutServer) {
|
|
136
134
|
lines.push(`\nimport type { load as _layoutLoad } from '${srcBase}+layout.server.ts';`);
|
|
137
135
|
lines.push(`export type LayoutServerLoad = (event: _LoadEvent) => any;`);
|
|
136
|
+
lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>>;`);
|
|
138
137
|
lines.push(
|
|
139
|
-
`export type
|
|
138
|
+
`export type LayoutProps = { data: LayoutData; params: Params; children: any };`,
|
|
140
139
|
);
|
|
141
|
-
lines.push(`export type LayoutProps = { data: LayoutData; children: any };`);
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
const outDir = join(process.cwd(), ".bosia", "types", "src", "routes", ...segments);
|
package/src/core/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
|
|
|
24
24
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
25
25
|
import { OUT_DIR } from "./paths.ts";
|
|
26
26
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
27
|
+
import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
27
28
|
import {
|
|
28
29
|
loadRouteData,
|
|
29
30
|
loadMetadata,
|
|
@@ -280,6 +281,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
280
281
|
}
|
|
281
282
|
if (isDev) console.error("Data endpoint error:", err);
|
|
282
283
|
else console.error("Data endpoint error:", (err as Error).message ?? err);
|
|
284
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
283
285
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
284
286
|
}
|
|
285
287
|
}
|
|
@@ -362,6 +364,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
362
364
|
} catch (err) {
|
|
363
365
|
if (isDev) console.error("API route error:", err);
|
|
364
366
|
else console.error("API route error:", (err as Error).message ?? err);
|
|
367
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
365
368
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
366
369
|
}
|
|
367
370
|
}
|
|
@@ -535,6 +538,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
535
538
|
}
|
|
536
539
|
if (isDev) console.error("Form action error:", err);
|
|
537
540
|
else console.error("Form action error:", (err as Error).message ?? err);
|
|
541
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
538
542
|
if (isEnhanced) {
|
|
539
543
|
return Response.json(
|
|
540
544
|
{ type: "error", status: 500, message: "Internal Server Error" },
|
|
@@ -639,6 +643,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
639
643
|
} catch (err) {
|
|
640
644
|
if (isDev) console.error("Unhandled request error:", err);
|
|
641
645
|
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
|
646
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
642
647
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
643
648
|
} finally {
|
|
644
649
|
inFlight--;
|
|
@@ -758,14 +763,15 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 :
|
|
|
758
763
|
// loose `Elysia` so plugin-extended types stay assignable.
|
|
759
764
|
let app: Elysia = new Elysia({
|
|
760
765
|
serve: { maxRequestBodySize: BODY_SIZE_LIMIT, idleTimeout: IDLE_TIMEOUT },
|
|
761
|
-
}).onError(({ error }) => {
|
|
762
|
-
if (isDev) console.error("Uncaught server error:", error);
|
|
763
|
-
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
764
|
-
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
765
766
|
}) as unknown as Elysia;
|
|
766
767
|
|
|
767
768
|
// Plugins.backend.before — runs before framework middleware/routes.
|
|
768
769
|
// Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
|
|
770
|
+
// Plugins register their own `.onError()` handlers here. Elysia fires onError
|
|
771
|
+
// handlers in registration order; plugin handlers run first and (when they
|
|
772
|
+
// return undefined) fall through to the base 500 responder chained after this
|
|
773
|
+
// loop. Registering the base responder before the loop would short-circuit
|
|
774
|
+
// every plugin handler.
|
|
769
775
|
for (const plugin of plugins) {
|
|
770
776
|
if (plugin.backend?.before) {
|
|
771
777
|
try {
|
|
@@ -777,6 +783,12 @@ for (const plugin of plugins) {
|
|
|
777
783
|
}
|
|
778
784
|
}
|
|
779
785
|
|
|
786
|
+
app = app.onError(({ error }) => {
|
|
787
|
+
if (isDev) console.error("Uncaught server error:", error);
|
|
788
|
+
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
789
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
790
|
+
}) as unknown as Elysia;
|
|
791
|
+
|
|
780
792
|
app = app
|
|
781
793
|
// Static files are served by resolve() with path traversal protection and security headers
|
|
782
794
|
// SSR pages
|
|
@@ -3,6 +3,29 @@ import type { BunPlugin } from "bun";
|
|
|
3
3
|
|
|
4
4
|
const svelteHash = (s: string) => Bun.hash(s, 5381).toString(36);
|
|
5
5
|
|
|
6
|
+
// Bun's bundler does not chain sourcemaps from `onLoad` results, so the final
|
|
7
|
+
// bundle map points at the compiled svelte output (e.g. `$.next()`) using the
|
|
8
|
+
// .svelte filename — runtime stacks resolve to nonsense line numbers past EOF.
|
|
9
|
+
// We capture each per-file svelte compile map here, keyed by absolute source
|
|
10
|
+
// path; `remapBundleSourcemaps()` reads these after `Bun.build` and rewrites
|
|
11
|
+
// the output `.map` files to chain back to original source positions.
|
|
12
|
+
export const svelteMapCache = new Map<string, unknown>();
|
|
13
|
+
|
|
14
|
+
// Svelte 5 dev compile emits named `function get()` / `function set($$value)`
|
|
15
|
+
// expressions inside `$.bind_*` calls (for nicer `$inspect` stack traces). Bun's
|
|
16
|
+
// bundler destructures `import * as $ from "svelte/internal/client"` into named
|
|
17
|
+
// imports, so `$.get(search)` becomes plain `get(search)` — which collides with
|
|
18
|
+
// the wrapping function name and recurses into itself → RangeError. Prod compile
|
|
19
|
+
// uses anonymous arrow functions and is unaffected.
|
|
20
|
+
//
|
|
21
|
+
// Rename to `$$g` / `$$s` (3 chars — length-preserving so cached svelte source
|
|
22
|
+
// map columns stay accurate). These names aren't present in svelte/internal/client.
|
|
23
|
+
function fixBindShadow(code: string): string {
|
|
24
|
+
return code
|
|
25
|
+
.replace(/\bfunction get\(\)/g, () => "function $$g()")
|
|
26
|
+
.replace(/\bfunction set\(\$\$value\)/g, () => "function $$s($$value)");
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
|
|
7
30
|
const generate = target === "browser" ? "client" : "server";
|
|
8
31
|
const dev = process.env.NODE_ENV !== "production";
|
|
@@ -25,7 +48,19 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
|
|
|
25
48
|
cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
|
|
26
49
|
filename: args.path,
|
|
27
50
|
});
|
|
28
|
-
|
|
51
|
+
// Only the client target's map is useful to the inspector's runtime
|
|
52
|
+
// resolver — browser-side stack frames are what we need to translate.
|
|
53
|
+
// Server (Bun) compile output has different line numbers and would
|
|
54
|
+
// clobber the client entry under the same cache key.
|
|
55
|
+
if (dev && target === "browser" && result.js.map) {
|
|
56
|
+
const m =
|
|
57
|
+
typeof result.js.map === "string"
|
|
58
|
+
? JSON.parse(result.js.map)
|
|
59
|
+
: result.js.map;
|
|
60
|
+
svelteMapCache.set(args.path, m);
|
|
61
|
+
}
|
|
62
|
+
const contents = dev ? fixBindShadow(result.js.code) : result.js.code;
|
|
63
|
+
return { contents, loader: "ts" };
|
|
29
64
|
});
|
|
30
65
|
|
|
31
66
|
build.onLoad({ filter: /\.svelte\.[tj]s$/ }, async (args) => {
|
|
@@ -38,6 +73,13 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
|
|
|
38
73
|
dev,
|
|
39
74
|
filename: args.path,
|
|
40
75
|
});
|
|
76
|
+
if (dev && target === "browser" && result.js.map) {
|
|
77
|
+
const m =
|
|
78
|
+
typeof result.js.map === "string"
|
|
79
|
+
? JSON.parse(result.js.map)
|
|
80
|
+
: result.js.map;
|
|
81
|
+
svelteMapCache.set(args.path, m);
|
|
82
|
+
}
|
|
41
83
|
return { contents: result.js.code, loader: "js" };
|
|
42
84
|
});
|
|
43
85
|
},
|
|
@@ -19,7 +19,8 @@ src/
|
|
|
19
19
|
├── lib/ # Shared utilities — import via $lib
|
|
20
20
|
└── routes/ # Pages and API endpoints
|
|
21
21
|
├── +layout.svelte # Root layout
|
|
22
|
-
└──
|
|
22
|
+
└── (public)/ # Public route group
|
|
23
|
+
└── +page.svelte # Home page (/)
|
|
23
24
|
public/ # Static assets
|
|
24
25
|
```
|
|
25
26
|
|
|
@@ -29,16 +30,19 @@ Add pages by creating `+page.svelte` files under `src/routes/`:
|
|
|
29
30
|
|
|
30
31
|
```
|
|
31
32
|
src/routes/
|
|
32
|
-
|
|
33
|
-
├──
|
|
34
|
-
|
|
35
|
-
└──
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
└── (public)/ # public route group — no URL segment
|
|
34
|
+
├── +page.svelte # /
|
|
35
|
+
├── about/
|
|
36
|
+
│ └── +page.svelte # /about
|
|
37
|
+
└── blog/
|
|
38
|
+
├── +page.svelte # /blog
|
|
39
|
+
└── [slug]/
|
|
40
|
+
├── +page.server.ts # server loader
|
|
41
|
+
└── +page.svelte # /blog/:slug
|
|
40
42
|
```
|
|
41
43
|
|
|
44
|
+
Bosia's convention: put public-facing pages under `(public)/`, and authenticated areas under their own group (e.g. `(app)/`, `(admin)/`). The parentheses make the segment a layout group — it groups routes without appearing in the URL.
|
|
45
|
+
|
|
42
46
|
### Server Loaders
|
|
43
47
|
|
|
44
48
|
Fetch data on the server before rendering:
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
27
|
<p class="text-muted-foreground mt-6 text-sm">
|
|
28
|
-
Edit <code class="bg-muted rounded px-1 py-0.5 font-mono text-xs"
|
|
29
|
-
|
|
28
|
+
Edit <code class="bg-muted rounded px-1 py-0.5 font-mono text-xs"
|
|
29
|
+
>src/routes/(public)/+page.svelte</code
|
|
30
|
+
> to get started
|
|
30
31
|
</p>
|
|
31
32
|
</main>
|
|
@@ -27,7 +27,7 @@ This page lives at routes/(public)/blog/[slug]/+page.svelte but is served at /bl
|
|
|
27
27
|
tags: ["routing", "dynamic"],
|
|
28
28
|
content: `A directory named [slug] creates a dynamic route segment that matches any URL value.
|
|
29
29
|
|
|
30
|
-
The matched value is available as params.slug inside +page.server.ts load() and inside the page component via
|
|
30
|
+
The matched value is available as params.slug inside +page.server.ts load() and inside the page component via the params prop.
|
|
31
31
|
|
|
32
32
|
The route matcher uses 3-pass priority: exact matches first, then dynamic segments, then catch-all routes.`,
|
|
33
33
|
},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type { PageProps } from "./$types";
|
|
3
3
|
|
|
4
|
-
let { data }:
|
|
4
|
+
let { data, params }: PageProps = $props();
|
|
5
5
|
|
|
6
6
|
const post = $derived(data.post);
|
|
7
|
-
const slug = $derived(data.slug ??
|
|
7
|
+
const slug = $derived(data.slug ?? params.slug ?? "");
|
|
8
8
|
</script>
|
|
9
9
|
|
|
10
10
|
<svelte:head>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|