bosia 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -145,9 +145,15 @@
145
145
  settleScroll();
146
146
  return;
147
147
  }
148
+ // A prefetch saved before an invalidation holds pre-mutation data — after a
149
+ // `use:enhance` success or `invalidate*()` it must not stand in for the refetch.
150
+ const invalidated = currentTick !== lastTick || appState.dirty.all;
148
151
  lastTick = currentTick;
149
152
 
150
- appState.form = null;
153
+ // Only a real navigation drops the action result. An invalidation re-run
154
+ // (same URL, new tick) is how `use:enhance` refreshes the page after a
155
+ // successful action — clearing here wiped the `form` it had just set.
156
+ if (navKey !== lastNavKey) appState.form = null;
151
157
  if (navDoneTimer) {
152
158
  clearTimeout(navDoneTimer);
153
159
  navDoneTimer = null;
@@ -214,7 +220,7 @@
214
220
 
215
221
  // Load components + data in parallel, then update state atomically
216
222
  // to avoid a flash of stale/empty data before the fetch completes.
217
- const cached = match.route.hasServerData ? consumePrefetch(path) : null;
223
+ const cached = match.route.hasServerData && !invalidated ? consumePrefetch(path) : null;
218
224
  prefetchCache.clear(); // clear remaining entries on navigation — matches SvelteKit behavior
219
225
  // Forward cached parent data for skipped layers so downstream loaders see
220
226
  // real parent() data, not {}. POST only when there's something to carry —
@@ -457,7 +463,7 @@
457
463
  {#if depth > 0 && layoutComponents.length > 0}
458
464
  {@render renderLayout(0, depth)}
459
465
  {:else}
460
- <ErrorComponent {...errorProps ?? {}} />
466
+ <ErrorComponent {...errorProps ?? {}} {params} />
461
467
  {/if}
462
468
  {:else if layoutComponents.length > 0}
463
469
  {@render renderLayout(0, layoutComponents.length)}
@@ -490,7 +496,7 @@
490
496
  {:else}
491
497
  <Layout {data} {params}>
492
498
  {#if ErrorComponent}
493
- <ErrorComponent {...errorProps ?? {}} />
499
+ <ErrorComponent {...errorProps ?? {}} {params} />
494
500
  {:else if PageComponent}
495
501
  <PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
496
502
  {:else}
@@ -6,16 +6,11 @@
6
6
  //
7
7
  // Bosia passes `params` as a prop to `+page.svelte` / `+layout.svelte` (see
8
8
  // App.svelte), mirroring the modern SvelteKit `$app/state` direction. Route
9
- // components should destructure `params` from `$props()`. `page.params` still
10
- // exists as a deprecated, working fallback (reactive, backed by
11
- // `appState.routeParams`) so legacy code keeps running — it will be removed in
12
- // 1.0.0.
9
+ // components should destructure `params` from `$props()`. `page.params` was
10
+ // removed in 1.1.0.
13
11
 
14
- import { appState } from "./appState.svelte.ts";
15
12
  import { router } from "./router.svelte.ts";
16
13
 
17
- let paramsWarned = false;
18
-
19
14
  class Page {
20
15
  // Real on the server too: the renderer seeds `router.currentRoute`/`.origin`
21
16
  // from the request immediately before each render (see `renderWithPageContext`
@@ -29,21 +24,6 @@ class Page {
29
24
  get pathname() {
30
25
  return this.#url.pathname;
31
26
  }
32
-
33
- /**
34
- * @deprecated Read route params from `$props()` instead:
35
- * `let { params } = $props()` in `+page.svelte` / `+layout.svelte`.
36
- * `page.params` is a temporary fallback and will be removed in 1.0.0.
37
- */
38
- get params() {
39
- if (process.env.NODE_ENV !== "production" && !paramsWarned) {
40
- paramsWarned = true;
41
- console.warn(
42
- "[bosia] page.params is deprecated — destructure `params` from $props() in your route component. It will be removed in 1.0.0.",
43
- );
44
- }
45
- return appState.routeParams;
46
- }
47
27
  }
48
28
 
49
29
  export const page = new Page();
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { createInspectorBunPlugin } from "./bun-plugin.ts";
4
4
  import { getOverlayScript } from "./overlay.ts";
5
- import { resolveFrame, resolveStack } from "./sourcemap.ts";
5
+ import { parseTopFrame, resolveFrame, resolveStack } from "./sourcemap.ts";
6
6
  import type { BosiaPlugin } from "../../types/plugin.ts";
7
7
 
8
8
  export interface InspectorOptions {
@@ -34,18 +34,6 @@ function buildEditorArgs(editor: string, file: string, line: number, col: number
34
34
  return ["-g", `${file}:${line}:${col}`];
35
35
  }
36
36
 
37
- // Parse the top frame out of a stack trace string. Best-effort.
38
- function parseTopFrame(
39
- stack: string | undefined,
40
- ): { file: string; line: number; col: number } | null {
41
- if (!stack) return null;
42
- const m =
43
- /\((https?:\/\/[^)]+|\/[^)]+):(\d+):(\d+)\)/.exec(stack) ||
44
- /at\s+(\S+):(\d+):(\d+)/.exec(stack) ||
45
- /@(\S+):(\d+):(\d+)/.exec(stack);
46
- return m ? { file: m[1], line: Number(m[2]), col: Number(m[3]) } : null;
47
- }
48
-
49
37
  // Module-scoped state for the error feature. Initialised lazily inside the
50
38
  // plugin so importing this module doesn't attach process-wide listeners.
51
39
  let processListenersInstalled = false;
@@ -293,6 +281,9 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
293
281
  return;
294
282
  }
295
283
  flushReplay(ctrl);
284
+ // Under the app server's 10s default idle timeout, or Bun drops the
285
+ // stream (ECONNRESET) and EventSource reconnects. Still breaks if
286
+ // IDLE_TIMEOUT < 5; use server.timeout(req, 0) if that ever matters.
296
287
  const ping = setInterval(() => {
297
288
  try {
298
289
  ctrl.enqueue(encode(":ping\n\n"));
@@ -300,7 +291,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
300
291
  clearInterval(ping);
301
292
  sseClients.delete(ctrl);
302
293
  }
303
- }, 25_000);
294
+ }, 5_000);
304
295
 
305
296
  request.signal.addEventListener("abort", () => {
306
297
  clearInterval(ping);
@@ -1,4 +1,5 @@
1
1
  import { safeJsonStringify } from "../../html.ts";
2
+ import { FRAME_RE } from "./sourcemap.ts";
2
3
 
3
4
  export interface OverlayConfig {
4
5
  aiEndpoint?: string;
@@ -209,12 +210,10 @@ if(ERR_ENABLED){
209
210
  function uid(){return "c-"+Math.random().toString(36).slice(2)+Date.now().toString(36)}
210
211
  function escapeHtml(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","'":"&#39;"}[c]})}
211
212
 
213
+ var FRAME_RE=new RegExp(${JSON.stringify(FRAME_RE.source)});
212
214
  function parseTopFrame(stack){
213
- if(!stack)return null;
214
- var m=/\\((https?:\\/\\/[^)]+|\\/[^)]+):(\\d+):(\\d+)\\)/.exec(stack)
215
- ||/at\\s+(\\S+):(\\d+):(\\d+)/.exec(stack)
216
- ||/@(\\S+):(\\d+):(\\d+)/.exec(stack);
217
- return m?{file:m[1],line:+m[2],col:+m[3]}:null;
215
+ var m=stack?FRAME_RE.exec(stack):null;
216
+ return m?{file:m[2],line:+m[3],col:+m[4]}:null;
218
217
  }
219
218
 
220
219
  function trackInteraction(e){
@@ -255,7 +254,7 @@ if(ERR_ENABLED){
255
254
  var locStr=er.file?(er.file+(er.line?":"+er.line:"")):er.source;
256
255
  rows+='<div data-row="'+er.id+'" style="padding:10px 12px;border-bottom:1px solid #f4f4f5">'
257
256
  +'<div style="font:11px ui-monospace,monospace;color:#71717a;margin-bottom:4px">['+escapeHtml(er.source)+'] '+escapeHtml(locStr)+'</div>'
258
- +'<div style="margin-bottom:6px;font-weight:500;word-break:break-word">'+escapeHtml(er.message)+'</div>'
257
+ +'<div style="margin-bottom:6px;font-weight:500;word-break:break-word">'+escapeHtml(er.message)+(er.count>1?' <span style="color:#71717a;font-weight:400">&times;'+er.count+'</span>':"")+'</div>'
259
258
  +(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>':"")
260
259
  +'<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">'
261
260
  +(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>':"")
@@ -304,7 +303,17 @@ if(ERR_ENABLED){
304
303
  var id=e.id||uid();
305
304
  if(seenIds[id])return;
306
305
  seenIds[id]=true;
306
+ // Same key as the server's dedup (source, message, top frame). Repeats
307
+ // bump a counter on the existing row instead of adding a new one.
308
+ var key=(e.source||"unknown")+"|"+(e.message||"")+"|"+(e.file||"")+":"+(e.line||"");
309
+ for(var i=0;i<errors.length;i++)if(errors[i].key===key){
310
+ errors[i].count++;
311
+ errors[i].ts=e.ts||Date.now();
312
+ renderBadge();
313
+ return;
314
+ }
307
315
  var entry={
316
+ key:key,count:1,
308
317
  id:id,
309
318
  ts:e.ts||Date.now(),
310
319
  source:e.source||"unknown",
@@ -123,15 +123,24 @@ function relToCwd(abs: string): string {
123
123
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosix(rel) : abs;
124
124
  }
125
125
 
126
- // Rewrite frames in stack strings: "(url:L:C)", "at url:L:C", "@url:L:C".
127
- // Lazy match on the file body so URLs with ports (`http://host:9000/...`) keep
128
- // the port as part of the file rather than being chopped at the first `:`.
126
+ // One stack frame: "(file:L:C)", "at file:L:C", "@file:L:C". The file body is
127
+ // lazy up to the first ":L:C", so ")" in route groups like `(public)` and the
128
+ // port in `http://host:9000/...` stay inside the file. Groups: lead, file, L, C, tail.
129
+ // The browser overlay reuses this source too (see overlay.ts).
130
+ export const FRAME_RE = /(\(|\bat\s+|@)((?:https?:\/\/|\/|[A-Za-z]:[\\/])[^\n]*?):(\d+):(\d+)(\)?)/;
131
+
132
+ // Top frame of a stack trace string. Best-effort.
133
+ export function parseTopFrame(
134
+ stack: string | undefined,
135
+ ): { file: string; line: number; col: number } | null {
136
+ const m = stack ? FRAME_RE.exec(stack) : null;
137
+ return m ? { file: m[2], line: Number(m[3]), col: Number(m[4]) } : null;
138
+ }
139
+
140
+ // Rewrite every frame in a stack string to its original source position.
129
141
  export function resolveStack(stack: string): string {
130
- return stack.replace(
131
- /(\(|at\s+|@)((?:https?:\/\/|\/)[^\s)]+?):(\d+):(\d+)(\)?)/g,
132
- (_m, lead, file, l, c, tail) => {
133
- const r = resolveFrame(file, Number(l), Number(c));
134
- return r ? `${lead}${r.file}:${r.line}:${r.col}${tail}` : _m;
135
- },
136
- );
142
+ return stack.replace(new RegExp(FRAME_RE.source, "g"), (_m, lead, file, l, c, tail) => {
143
+ const r = resolveFrame(file, Number(l), Number(c));
144
+ return r ? `${lead}${r.file}:${r.line}:${r.col}${tail}` : _m;
145
+ });
137
146
  }
@@ -23,7 +23,6 @@ import { HttpError, Redirect, isHttpError, isRedirect } from "./errors.ts";
23
23
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
24
24
  import App from "./client/App.svelte";
25
25
  import { router } from "./client/router.svelte.ts";
26
- import { appState } from "./client/appState.svelte.ts";
27
26
  import { withBase } from "./basePath.ts";
28
27
  import { currentBase } from "./appBase.ts";
29
28
  import {
@@ -92,15 +91,9 @@ const enc = new TextEncoder();
92
91
  // `render`'s own signature is conditional on the component's prop type, so it
93
92
  // collapses to `never` behind a generic wrapper — hence `any` on both, matching
94
93
  // how the call sites already type `pageMod.default` / `layoutMods`.
95
- function renderWithPageContext(
96
- component: any,
97
- options: { props?: Record<string, any> },
98
- url: URL,
99
- params: Record<string, string> = {},
100
- ) {
94
+ function renderWithPageContext(component: any, options: { props?: Record<string, any> }, url: URL) {
101
95
  router.origin = url.origin;
102
96
  router.currentRoute = withBase(currentBase(), url.pathname) + url.search + url.hash;
103
- appState.routeParams = params;
104
97
  return render(component, options as any);
105
98
  }
106
99
 
@@ -805,7 +798,6 @@ export async function renderSSRStream(
805
798
  },
806
799
  },
807
800
  url,
808
- params,
809
801
  ));
810
802
  } catch (err) {
811
803
  if (isDev) console.error("SSR render error:", err);
@@ -1056,7 +1048,6 @@ export async function renderPageWithFormData(
1056
1048
  },
1057
1049
  },
1058
1050
  url,
1059
- params,
1060
1051
  );
1061
1052
 
1062
1053
  const html = buildHtml(
@@ -1117,10 +1108,9 @@ export async function renderErrorPage(
1117
1108
  };
1118
1109
  const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
1119
1110
 
1120
- // The failing route's own params, so a nested +error.svelte under
1121
- // /blog/[slug] still sees its `slug`. A 404 matches nothing and yields {},
1122
- // which is also what has to be seeded rather than left holding the previous
1123
- // request's values.
1111
+ // The failing route's own params, so +error.svelte (and the layouts wrapping
1112
+ // a nested one) under /blog/[slug] get `params.slug`. A 404 matches nothing
1113
+ // and yields {}.
1124
1114
  const errorParams = findMatch(serverRoutes, url.pathname)?.params ?? {};
1125
1115
 
1126
1116
  // 1. Nested boundary
@@ -1150,10 +1140,10 @@ export async function renderErrorPage(
1150
1140
  ssrErrorComponent: errorMod.default,
1151
1141
  ssrErrorProps: { error: { status, message } },
1152
1142
  ssrErrorDepth: K,
1143
+ ssrPageData: { params: errorParams },
1153
1144
  },
1154
1145
  },
1155
1146
  url,
1156
- errorParams,
1157
1147
  );
1158
1148
  // csr=false: no client hydration on the error page itself.
1159
1149
  const html = buildHtml(
@@ -1191,9 +1181,8 @@ export async function renderErrorPage(
1191
1181
  // expects `error` as a direct prop: `let { error } = $props()`.
1192
1182
  const { body, head } = renderWithPageContext(
1193
1183
  mod.default,
1194
- { props: { error: { status, message } } },
1184
+ { props: { error: { status, message }, params: errorParams } },
1195
1185
  url,
1196
- errorParams,
1197
1186
  );
1198
1187
  const html = buildHtml(
1199
1188
  body,
@@ -123,7 +123,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
123
123
  if (info.hasErrorPage) {
124
124
  lines.push(``);
125
125
  lines.push(`export type PageError = { status: number; message: string };`);
126
- lines.push(`export type ErrorProps = { error: PageError };`);
126
+ lines.push(
127
+ `export type ErrorProps = { error: PageError; params: Params & Record<string, string | undefined> };`,
128
+ );
127
129
  }
128
130
 
129
131
  // ActionData — union of all action return types, unwrapping ActionFailure