@ogygia/devtools 0.12.0 → 0.13.0

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.
Files changed (79) hide show
  1. package/dist/app.d.ts +3 -0
  2. package/dist/app.js +9 -0
  3. package/dist/devtools/BoundaryOverlay.svelte +10 -6
  4. package/dist/devtools/Devtools.svelte +24 -18
  5. package/dist/devtools/IslandDetail.svelte +7 -5
  6. package/dist/devtools/PageTab.svelte +4 -2
  7. package/dist/devtools/ProfilerTab.svelte +8 -2
  8. package/dist/devtools/SessionTab.svelte +21 -8
  9. package/dist/devtools/highlight.js +14 -7
  10. package/dist/devtools/ledger-dom.js +6 -9
  11. package/dist/devtools/names.js +18 -17
  12. package/dist/devtools/page.d.ts +2 -0
  13. package/dist/devtools/page.js +37 -17
  14. package/dist/devtools/profile-store.d.ts +4 -9
  15. package/dist/devtools/profile-store.js +26 -21
  16. package/dist/devtools/session.d.ts +2 -0
  17. package/dist/devtools/session.js +48 -42
  18. package/dist/devtools/testing-engine.js +10 -1
  19. package/dist/devtools/ui-app.d.ts +3 -1
  20. package/dist/devtools/ui-app.js +9 -3
  21. package/dist/devtools/ui.js +83 -40
  22. package/dist/messages/build.d.ts +21 -0
  23. package/dist/messages/build.js +56 -0
  24. package/dist/messages/index.d.ts +3 -0
  25. package/dist/messages/index.js +3 -0
  26. package/dist/messages/runtime.d.ts +63 -0
  27. package/dist/messages/runtime.js +122 -0
  28. package/dist/profiler/async-io.d.ts +12 -7
  29. package/dist/profiler/async-io.js +94 -82
  30. package/dist/profiler/beacon-standalone.js +15 -6
  31. package/dist/profiler/busy.d.ts +13 -0
  32. package/dist/profiler/busy.js +45 -0
  33. package/dist/profiler/client.js +5 -24
  34. package/dist/profiler/embedded-maps.js +9 -5
  35. package/dist/profiler/gc.d.ts +5 -0
  36. package/dist/profiler/gc.js +75 -11
  37. package/dist/profiler/index.d.ts +23 -2
  38. package/dist/profiler/index.js +648 -373
  39. package/dist/profiler/net.d.ts +14 -6
  40. package/dist/profiler/net.js +61 -45
  41. package/dist/profiler/patterns.js +29 -1
  42. package/dist/profiler/source-scan.js +24 -12
  43. package/dist/profiler/storage/index.d.ts +14 -3
  44. package/dist/profiler/storage/index.js +41 -41
  45. package/dist/profiler/storage/postgres.d.ts +18 -5
  46. package/dist/profiler/storage/postgres.js +124 -85
  47. package/dist/profiler/storage/redis.d.ts +18 -5
  48. package/dist/profiler/storage/redis.js +160 -131
  49. package/dist/profiler/storage/sqlite.d.ts +15 -2
  50. package/dist/profiler/storage/sqlite.js +54 -48
  51. package/dist/profiler/ui/ComponentsTable.svelte +2 -2
  52. package/dist/profiler/ui/Dashboard.svelte +2 -3
  53. package/dist/profiler/ui/DownloadHtml.svelte +1 -0
  54. package/dist/profiler/ui/ExportButton.svelte +1 -0
  55. package/dist/profiler/ui/Flame.svelte +10 -4
  56. package/dist/profiler/ui/Flame.svelte.d.ts +0 -5
  57. package/dist/profiler/ui/FrameDetails.svelte +13 -2
  58. package/dist/profiler/ui/FrameDetails.svelte.d.ts +0 -6
  59. package/dist/profiler/ui/FunctionsTable.svelte +2 -2
  60. package/dist/profiler/ui/IslandsTable.svelte +2 -2
  61. package/dist/profiler/ui/OnThisPage.svelte +12 -4
  62. package/dist/profiler/ui/RunView.svelte +31 -27
  63. package/dist/profiler/ui/ShareLink.svelte +6 -1
  64. package/dist/profiler/ui/Sidebar.svelte +14 -9
  65. package/dist/profiler/ui/Treemap.svelte +11 -4
  66. package/dist/profiler/ui/Treemap.svelte.d.ts +0 -6
  67. package/dist/profiler/ui/WaitingTable.svelte +2 -2
  68. package/dist/profiler/ui/fake-progress.svelte.d.ts +7 -12
  69. package/dist/profiler/ui/fake-progress.svelte.js +57 -36
  70. package/dist/profiler/ui/press.js +4 -2
  71. package/dist/profiler/ui/row-anchor.svelte.d.ts +0 -6
  72. package/dist/profiler/ui/row-anchor.svelte.js +13 -8
  73. package/dist/profiler/ui/sort.svelte.d.ts +9 -6
  74. package/dist/profiler/ui/sort.svelte.js +34 -28
  75. package/dist/profiler/visit.d.ts +0 -1
  76. package/dist/profiler/visit.js +1 -2
  77. package/dist/server.js +27 -10
  78. package/dist/vite.js +3 -2
  79. package/package.json +13 -4
package/dist/app.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { ServerApp } from "@ogygia/core/plugin";
2
+ export declare function set_devtools_app(app: ServerApp<unknown> | null): void;
3
+ export declare function devtools_app(): ServerApp<unknown> | null;
package/dist/app.js ADDED
@@ -0,0 +1,9 @@
1
+ // src/app.ts
2
+ const slot = globalThis[Symbol.for("ogygia.devtools.app.v1")] ??= { app: null };
3
+ function set_devtools_app(app) {
4
+ slot.app = app;
5
+ }
6
+ function devtools_app() {
7
+ return slot.app;
8
+ }
9
+ export { devtools_app, set_devtools_app };
@@ -15,6 +15,7 @@
15
15
  * the real element under the cursor via `<svelte:window>`. A pure consumer, drawn only while on.
16
16
  */
17
17
  import { onMount } from 'svelte';
18
+ import { scope } from '@ogygia/machine';
18
19
  import {
19
20
  all_regions,
20
21
  region_info,
@@ -45,19 +46,22 @@
45
46
  const onkey = (e) => {
46
47
  if (e.key === 'Escape' && picking) picking = false;
47
48
  };
48
- window.addEventListener('keydown', onkey);
49
- return () => window.removeEventListener('keydown', onkey);
49
+ const s = scope();
50
+ s.on(window, 'keydown', onkey);
51
+ return () => s.close();
50
52
  });
51
53
  // The rAF loop runs ONLY while the overlay is on (it follows the `overlay` prop, hence an effect):
52
54
  // a loop left running with the overlay off asked the browser for a frame sixty times a second on
53
55
  // every page with devtools, the dock closed — ~90 ms of main-thread work each 5 s of idle
54
56
  $effect(() => {
55
57
  if (!overlay) return;
56
- let raf = requestAnimationFrame(function loop() {
58
+ const s = scope();
59
+ const loop = () => {
57
60
  frame++;
58
- raf = requestAnimationFrame(loop);
59
- });
60
- return () => cancelAnimationFrame(raf);
61
+ s.timeout(loop, 'frame');
62
+ };
63
+ s.timeout(loop, 'frame');
64
+ return () => s.close();
61
65
  });
62
66
 
63
67
  const boxes = $derived.by(() => {
@@ -29,7 +29,8 @@ shadow boundary keeps it from the dock — a built dock opened unstyled. -->
29
29
  import PageTab from './PageTab.svelte';
30
30
  import SessionTab from './SessionTab.svelte';
31
31
  import HydrationTab from './HydrationTab.svelte';
32
- import { onMount } from 'svelte';
32
+ import { onDestroy, onMount } from 'svelte';
33
+ import { scope, STALE } from '@ogygia/machine';
33
34
  import BoundaryOverlay from './BoundaryOverlay.svelte';
34
35
  import IslandDetail from './IslandDetail.svelte';
35
36
  import { to_trace } from '@ogygia/core/events';
@@ -93,7 +94,7 @@ shadow boundary keeps it from the dock — a built dock opened unstyled. -->
93
94
  JSON.stringify({ open, launch: launchPos, win: winPos, rz: winResizeOff, size: winSize })
94
95
  );
95
96
  } catch {
96
- /* private mode / quota — layout just won't persist */
97
+ // ok: private mode or quota: the layout is not kept
97
98
  }
98
99
  }
99
100
 
@@ -155,15 +156,19 @@ shadow boundary keeps it from the dock — a built dock opened unstyled. -->
155
156
  // Explicit hover state for the launcher (pointerenter/leave — a `:hover` descendant selector proved
156
157
  // flaky against neodrag's inline transform). `up` = fully revealed; otherwise it peeks below the fold.
157
158
  let launchHover = $state(false);
158
- let tickTimer = /** @type {ReturnType<typeof setInterval> | null} */ (null);
159
+ /** The dock's own work in the page (its keys, its timers, its fetch): let go when it unmounts. */
160
+ const owner = scope();
161
+ onDestroy(() => owner.close());
162
+ /** The live refresh while the window is open: a child of the dock's scope. */
163
+ let refresh = /** @type {import('@ogygia/machine').Scope | null} */ (null);
159
164
  function refresh_on() {
160
- if (!tickTimer) tickTimer = setInterval(() => tick++, 700);
165
+ if (refresh) return;
166
+ refresh = owner.child();
167
+ refresh.every(() => tick++, 700);
161
168
  }
162
169
  function refresh_off() {
163
- if (tickTimer) {
164
- clearInterval(tickTimer);
165
- tickTimer = null;
166
- }
170
+ refresh?.close();
171
+ refresh = null;
167
172
  }
168
173
  function place_near_launcher() {
169
174
  winPos = {
@@ -216,10 +221,12 @@ shadow boundary keeps it from the dock — a built dock opened unstyled. -->
216
221
  a.href = url;
217
222
  a.download = `ogygia-trace-${Date.now()}.json`;
218
223
  a.click();
224
+ // (the download is the page's, not the dock's: it lets go of the file even if the dock goes)
225
+ // oxlint-disable-next-line ogygia/owned-effects -- one-shot, ends itself, outlives the dock on purpose
219
226
  setTimeout(() => URL.revokeObjectURL(url), 1000);
220
227
  copied = 'saved as a file';
221
228
  }
222
- setTimeout(() => (copied = ''), 1600);
229
+ owner.timeout(() => (copied = ''), 1600);
223
230
  }
224
231
 
225
232
  // One-time setup: Alt+O toggle, the compiler's devtools metadata, and (re)starting the refresh timer
@@ -231,28 +238,27 @@ shadow boundary keeps it from the dock — a built dock opened unstyled. -->
231
238
  toggle_open();
232
239
  }
233
240
  };
234
- window.addEventListener('keydown', onkey);
241
+ owner.on(window, 'keydown', onkey);
235
242
 
236
243
  // `names` (island id → component name) and `bytes` (island id → transitive dev-graph size) label
237
244
  // the tabs with real names/costs. Best-effort — the endpoint only exists on the dev server (a
238
245
  // build carries its names in the dock's own module: ui-app.ts).
239
246
  if (!LAZY)
240
- fetch('/__ogygia_devtools_meta')
241
- .then((r) => (r.ok ? r.json() : null))
247
+ owner
248
+ .task((signal) =>
249
+ fetch('/__ogygia_devtools_meta', { signal }).then((r) => (r.ok ? r.json() : null))
250
+ )
242
251
  .then((meta) => {
243
- if (!meta || typeof meta !== 'object') return;
252
+ // (STALE: the dock unmounted first)
253
+ if (!meta || meta === STALE || typeof meta !== 'object') return;
244
254
  if (meta.names) window.__ogygia_region_names = meta.names;
245
255
  if (meta.bytes) window.__ogygia_region_bytes = meta.bytes;
246
256
  tick++;
247
257
  })
248
- .catch(() => {});
258
+ .catch(() => {}); // ok: best effort (dev server only): without names the tabs show hashes
249
259
 
250
260
  if (startOpen && !open) set_open(true);
251
261
  else if (open) refresh_on();
252
- return () => {
253
- window.removeEventListener('keydown', onkey);
254
- refresh_off();
255
- };
256
262
  });
257
263
  </script>
258
264
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { parse } from 'devalue';
14
14
  import { onDestroy } from 'svelte';
15
+ import { scope } from '@ogygia/machine';
15
16
  import { highlight, clear_highlight } from './highlight.js';
16
17
  import {
17
18
  region_info,
@@ -142,14 +143,15 @@
142
143
  });
143
144
 
144
145
  // scroll to it AND mark it for a moment: an island already on screen gave no sign at all
145
- /** @type {ReturnType<typeof setTimeout> | undefined} */
146
- let flash;
146
+ const owner = scope();
147
+ onDestroy(() => owner.close());
148
+ /** @type {(() => void) | null} */
149
+ let unflash = null;
147
150
  function locate() {
148
151
  highlight([el], model.name, true);
149
- clearTimeout(flash);
150
- flash = setTimeout(clear_highlight, 1400);
152
+ unflash?.();
153
+ unflash = owner.timeout(clear_highlight, 1400);
151
154
  }
152
- onDestroy(() => clearTimeout(flash));
153
155
  </script>
154
156
 
155
157
  <div class="detail" data-og-detail>
@@ -9,6 +9,7 @@
9
9
  * waterfall of every island's wake (module → turn → hydrate, against FCP / LCP / DCL / load), the
10
10
  * files that blocked the first paint, and the bytes by type. The analysis is page-insights.ts.
11
11
  */
12
+ import { scope } from '@ogygia/machine';
12
13
  import { read_page, cpu_of, summary_lines } from './page.js';
13
14
  import { first_difference, rate } from '@ogygia/core/insights';
14
15
  import { beacon_record_cpu } from '@ogygia/core/internal/instruments';
@@ -48,14 +49,15 @@
48
49
  const nav_key = $derived(view?.nav?.t ?? 0);
49
50
  $effect(() => {
50
51
  void nav_key;
51
- const id = setTimeout(
52
+ const s = scope();
53
+ s.timeout(
52
54
  () => {
53
55
  check_styles();
54
56
  check_cache();
55
57
  },
56
58
  nav_key ? 400 : 60
57
59
  );
58
- return () => clearTimeout(id);
60
+ return () => s.close();
59
61
  });
60
62
  const findings = $derived.by(() => {
61
63
  if (!view) return [];
@@ -12,6 +12,7 @@
12
12
  * the rest of devtools, so the profiler answers without a key.
13
13
  */
14
14
  import { onMount } from 'svelte';
15
+ import { scope } from '@ogygia/machine';
15
16
  import { run_profile, profile_for, profiles_version } from './profile-store.js';
16
17
  import { page_anchors } from './profile-anchors.js';
17
18
  import { highlight, clear_highlight } from './highlight.js';
@@ -27,13 +28,18 @@
27
28
  let error = $state('');
28
29
  let bump = $state(0); // a new profile landed
29
30
 
31
+ /** The tab's profiles (their requests, their waits for a busy profiler): end with the tab. */
32
+ const owner = scope();
30
33
  onMount(() => {
31
34
  path = location.pathname;
32
35
  // Probe: a mounted profiler answers its base (200/redirect); an un-mounted one 404s.
33
36
  fetch(BASE, { method: 'GET', redirect: 'manual' })
34
37
  .then((r) => (available = r.status !== 404))
35
38
  .catch(() => (available = false));
36
- return () => clear_highlight(); // leaving the tab takes the boxes off the page
39
+ return () => {
40
+ clear_highlight(); // leaving the tab takes the boxes off the page
41
+ owner.close();
42
+ };
37
43
  });
38
44
 
39
45
  const prof = $derived.by(() => {
@@ -57,7 +63,7 @@
57
63
  started = performance.now();
58
64
  const p = path.startsWith('/') ? path : '/' + path;
59
65
  try {
60
- await run_profile(p, runs, BASE);
66
+ if ((await run_profile(p, runs, BASE, owner)) === null) return; // the tab was left
61
67
  bump++;
62
68
  } catch (e) {
63
69
  error = `The profile failed: ${e instanceof Error ? e.message : String(e)}`;
@@ -20,6 +20,7 @@
20
20
  * Engine: session.ts; analysis: session-insights.ts.
21
21
  */
22
22
  import { onMount } from 'svelte';
23
+ import { flights, scope } from '@ogygia/machine';
23
24
  import { start_session, recording, set_last_session, session_names } from './session.js';
24
25
  import { run_profile, slim_profile } from './profile-store.js';
25
26
  import { analyze_session, session_timeline } from '@ogygia/core/insights';
@@ -51,13 +52,19 @@
51
52
  let server_window = $state(
52
53
  /** @type {import('./profile-store.js').SlimProfile & { window?: any } | null} */ (null)
53
54
  );
54
- /** @type {Promise<boolean> | null} */
55
- let probe = null;
55
+ /** Is the profiler here? Asked once and kept; a probe the network failed is not kept (the next
56
+ * ask probes again), and reads as no for now. */
57
+ const probe = flights({
58
+ name: 'devtools profiler probe',
59
+ run: () =>
60
+ fetch('/__profiler', { redirect: 'manual' }).then((r) => (profiler_here = r.status !== 404))
61
+ });
56
62
  const probe_profiler = () =>
57
- (probe ??= fetch('/__profiler', { redirect: 'manual' })
58
- .then((r) => (profiler_here = r.status !== 404))
59
- .catch(() => (profiler_here = false)));
60
- onMount(() => void probe_profiler());
63
+ probe.get().then(
64
+ (here) => here === true,
65
+ () => (profiler_here = false)
66
+ );
67
+ onMount(() => void probe_profiler()); // ok: never rejects (a failed probe reads as no profiler)
61
68
  let window_on = false;
62
69
  /** the window's start in flight (a stop pressed before it answered waits, then stops it) */
63
70
  let window_starting = /** @type {Promise<void> | null} */ (null);
@@ -174,12 +181,15 @@
174
181
  let server = $state(
175
182
  /** @type {Record<string, { busy?: boolean; error?: string; p?: import('./profile-store.js').SlimProfile }>} */ ({})
176
183
  );
184
+ /** The tab's server profiles (their requests, their waits for a busy profiler): end with the tab. */
185
+ const owner = scope();
177
186
  async function profile_request(url) {
178
187
  const u = new URL(url);
179
188
  const path = u.pathname + u.search;
180
189
  server = { ...server, [url]: { busy: true } };
181
190
  try {
182
- const p = await run_profile(path, 3);
191
+ const p = await run_profile(path, 3, '/__profiler', owner);
192
+ if (p === null) return; // the tab was left
183
193
  server = { ...server, [url]: { p } };
184
194
  } catch (e) {
185
195
  server = { ...server, [url]: { error: e instanceof Error ? e.message : String(e) } };
@@ -193,7 +203,10 @@
193
203
  ? (n / 1024).toFixed(1) + ' KB'
194
204
  : (n / 1048576).toFixed(1) + ' MB';
195
205
 
196
- onMount(() => () => clear_highlight());
206
+ onMount(() => () => {
207
+ clear_highlight();
208
+ owner.close();
209
+ });
197
210
  </script>
198
211
 
199
212
  <div class="rec" data-og-session>
@@ -1,3 +1,4 @@
1
+ import { scope } from "@ogygia/machine";
1
2
  // src/devtools/highlight.ts
2
3
  /**
3
4
  * Light up MANY elements on the page at once — the profile's "these 290 shadow roots", "every
@@ -9,14 +10,16 @@ let root = null;
9
10
  let layer = null;
10
11
  let current = [];
11
12
  let label = "";
12
- let frame = 0;
13
+ /** The layer while it is shown: its scroll and resize watch and its queued frame. Hiding it closes. */
14
+ let shown = null;
15
+ let queued = false;
13
16
  const MAX_BOXES = 400;
14
17
  /** The dock's shadow root (ui-app.ts sets it at mount). */
15
18
  function set_highlight_root(r) {
16
19
  root = r;
17
20
  }
18
21
  function draw() {
19
- frame = 0;
22
+ queued = false;
20
23
  if (!layer) return;
21
24
  layer.textContent = "";
22
25
  const vw = innerWidth;
@@ -40,7 +43,9 @@ function draw() {
40
43
  }
41
44
  }
42
45
  const schedule = () => {
43
- if (!frame) frame = requestAnimationFrame(draw);
46
+ if (queued || !shown) return;
47
+ queued = true;
48
+ shown.timeout(draw, "frame");
44
49
  };
45
50
  /** Show boxes over `els` (with a caption); `scroll` brings the first into view. */
46
51
  function highlight(els, caption = "", scroll = false) {
@@ -52,11 +57,12 @@ function highlight(els, caption = "", scroll = false) {
52
57
  layer.setAttribute("data-og-highlight", "");
53
58
  layer.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483600";
54
59
  root.prepend(layer);
55
- addEventListener("scroll", schedule, {
60
+ shown = scope();
61
+ shown.on(globalThis, "scroll", schedule, {
56
62
  passive: true,
57
63
  capture: true
58
64
  });
59
- addEventListener("resize", schedule, { passive: true });
65
+ shown.on(globalThis, "resize", schedule, { passive: true });
60
66
  }
61
67
  if (scroll && els[0]) try {
62
68
  els[0].scrollIntoView({
@@ -72,8 +78,9 @@ function clear_highlight() {
72
78
  if (layer) {
73
79
  layer.remove();
74
80
  layer = null;
75
- removeEventListener("scroll", schedule, { capture: true });
76
- removeEventListener("resize", schedule);
81
+ shown?.close();
82
+ shown = null;
83
+ queued = false;
77
84
  }
78
85
  }
79
86
  /** How many boxes are shown now (tests). */
@@ -2,6 +2,7 @@ import { byte_ledger } from "./bytes.js";
2
2
  import { all_regions, region_name } from "./regions.js";
3
3
  import { dynamic_imports, static_imports } from "@ogygia/core/insights";
4
4
  import { ISLAND_GRAPH_ATTR, decode_island_graph, tool_fetch, tool_made } from "@ogygia/core/internal/instruments";
5
+ import { STALE, flights } from "@ogygia/machine";
5
6
  // src/devtools/ledger-dom.ts
6
7
  /**
7
8
  * The byte ledger's DOM side: the page's island graph (`script[data-ogygia-graph]`, a build writes
@@ -41,15 +42,11 @@ function runtime_parts(src) {
41
42
  return parts.read;
42
43
  }
43
44
  async function read_parts(src) {
44
- const cache = /* @__PURE__ */ new Map();
45
- const text = (u) => {
46
- let t = cache.get(u);
47
- if (!t) {
48
- t = tool_fetch(u, { cache: "force-cache" }).then((r) => r.ok ? r.text() : "").catch(() => "");
49
- cache.set(u, t);
50
- }
51
- return t;
52
- };
45
+ const texts = flights({
46
+ name: "devtools runtime parts",
47
+ run: (u) => tool_fetch(u, { cache: "force-cache" }).then((r) => r.ok ? r.text() : "")
48
+ });
49
+ const text = (u) => texts.get(u).then((t) => t === STALE ? "" : t, () => "");
53
50
  const self = import.meta.url;
54
51
  const roots = /* @__PURE__ */ new Map();
55
52
  const dock = [];
@@ -1,15 +1,16 @@
1
+ import { flights } from "@ogygia/machine";
1
2
  import { names } from "virtual:ogygia/devtools-meta";
2
3
  // src/devtools/names.ts
3
4
  /**
4
5
  * The islands' component names for the dock and the testing API: a build carries them in
5
6
  * `virtual:ogygia/devtools-meta`; the dev server serves them live (`/__ogygia_devtools_meta`, the
6
7
  * registry as it is now). Both land on `window.__ogygia_region_names`, which regions.ts reads.
7
- * Once per page; best effort (a hash stands in for a name it cannot find).
8
+ * Once per page; best effort (a hash stands in for a name it cannot find). A fetch that failed (the
9
+ * network, not an answer) is not kept: the next ask fetches again.
8
10
  */
9
- let loading = null;
10
- function ensure_region_names() {
11
- if (loading) return loading;
12
- loading = (async () => {
11
+ const loading = flights({
12
+ name: "devtools region names",
13
+ run: async () => {
13
14
  if (typeof window === "undefined") return;
14
15
  if (names && Object.keys(names).length) {
15
16
  window.__ogygia_region_names = {
@@ -18,17 +19,17 @@ function ensure_region_names() {
18
19
  };
19
20
  return;
20
21
  }
21
- try {
22
- const r = await fetch("/__ogygia_devtools_meta");
23
- if (!r.ok) return;
24
- const meta = await r.json();
25
- if (meta?.names) window.__ogygia_region_names = {
26
- ...meta.names,
27
- ...window.__ogygia_region_names
28
- };
29
- if (meta?.bytes) window.__ogygia_region_bytes = meta.bytes;
30
- } catch {}
31
- })();
32
- return loading;
22
+ const r = await fetch("/__ogygia_devtools_meta");
23
+ if (!r.ok) return;
24
+ const meta = await r.json().catch(() => null);
25
+ if (meta?.names) window.__ogygia_region_names = {
26
+ ...meta.names,
27
+ ...window.__ogygia_region_names
28
+ };
29
+ if (meta?.bytes) window.__ogygia_region_bytes = meta.bytes;
30
+ }
31
+ });
32
+ function ensure_region_names() {
33
+ return loading.get().then(() => {}, () => {});
33
34
  }
34
35
  export { ensure_region_names };
@@ -34,6 +34,8 @@ export interface PageView {
34
34
  holes_failed?: HoleFailure[];
35
35
  }
36
36
  export declare function region_facts(): RegionFact[];
37
+ export declare function prebundled_packages(script_urls: readonly string[]): string[] | null;
38
+ export declare function vary_of(url: string): string | null;
37
39
  /** This page's hydration failures, each island once: after an in-app navigation, the ones since it,
38
40
  * and an earlier one only while its region is still on the page and still not awake (a kept
39
41
  * island). Every visit to a page with a broken island failed it again: without this, the tab listed
@@ -6,6 +6,7 @@ import { with_source_lines } from "./source-lines.js";
6
6
  import { since_load } from "./since-load.js";
7
7
  import { analyze_cpu, analyze_page, browser_of, defer_keys, dev_compile_ms, fp_compare, is_trace, prebundled_names, vital_parts } from "@ogygia/core/insights";
8
8
  import { PAGE_DEFER_KEY, PAGE_DEFER_REGISTRY_KEY, PAGE_SEED_SELECTOR, beacon_page, hole_request_times, is_headless, tool_fetch } from "@ogygia/core/internal/instruments";
9
+ import { flights } from "@ogygia/machine";
9
10
  import { snapshot } from "@ogygia/core/events";
10
11
  // src/devtools/page.ts
11
12
  /**
@@ -166,29 +167,42 @@ function shown_under_media(el) {
166
167
  /** THE DEV SERVER'S PRE-BUNDLED PACKAGES, read once from its `.vite/deps/_metadata.json` (the folder of
167
168
  * a pre-bundled script the page loaded — no guess at the app's base): the names its `optimized` keys
168
169
  * belong to. Null until read; never off the dev server, nor on a page that loaded no pre-bundled file. */
169
- let prebundled = null;
170
- let prebundled_asked = false;
170
+ const prebundled = flights({
171
+ name: "devtools prebundled packages",
172
+ run: async (dir, signal) => {
173
+ const r = await tool_fetch(dir + "_metadata.json", { signal });
174
+ if (r.status >= 500) throw new Error(`status ${r.status}`);
175
+ const m = r.ok ? await r.json() : null;
176
+ return prebundled_names(Object.keys(m?.optimized ?? {}));
177
+ }
178
+ });
171
179
  function prebundled_packages(script_urls) {
172
- if (!import.meta.env.DEV || prebundled_asked) return prebundled;
180
+ if (!import.meta.env.DEV) return null;
173
181
  const one = script_urls.find((u) => u.includes("/node_modules/.vite/deps/"));
174
182
  if (!one) return null;
175
- prebundled_asked = true;
176
- const dir = one.slice(0, one.indexOf("/node_modules/.vite/deps/") + 25);
177
- tool_fetch(dir + "_metadata.json").then((r) => r.ok ? r.json() : null).then((m) => {
178
- prebundled = prebundled_names(Object.keys(m?.optimized ?? {}));
179
- }).catch(() => {});
180
- return null;
183
+ return asked(prebundled, one.slice(0, one.indexOf("/node_modules/.vite/deps/") + 25));
181
184
  }
182
185
  /** A FILE THE BROWSER DOWNLOADED AGAIN: its answer's `Vary` header, asked once per URL with a HEAD
183
186
  * (a tool's request, out of every measurement). Null until it is in, or when the answer has none. */
184
- const varies = /* @__PURE__ */ new Map();
185
- function vary_of(url) {
186
- if (varies.has(url)) return varies.get(url) ?? null;
187
- varies.set(url, null);
188
- tool_fetch(url, {
187
+ const varies = flights({
188
+ name: "devtools vary",
189
+ run: (url, signal) => tool_fetch(url, {
189
190
  method: "HEAD",
190
- cache: "no-store"
191
- }).then((r) => varies.set(url, r.headers.get("vary"))).catch(() => {});
191
+ cache: "no-store",
192
+ signal
193
+ }).then((r) => r.headers.get("vary"))
194
+ });
195
+ function vary_of(url) {
196
+ return asked(varies, url);
197
+ }
198
+ /** What the dev server answered for `key`, read without waiting: the kept answer, or null while it
199
+ * is asked (the first read starts the ask). An answer is kept for the session; a failed ask is not,
200
+ * so a later read asks again. A read while the document goes (the `pagehide` save) asks nothing: the
201
+ * request could not land, and WebKit says the cancelled fetch as a page error. */
202
+ function asked(f, key) {
203
+ const e = f.peek(key);
204
+ if (e?.kind === "kept") return e.answer;
205
+ if (e === void 0 && !leaving) f.get(key).catch(() => {});
192
206
  return null;
193
207
  }
194
208
  function first_child_top(el) {
@@ -902,15 +916,21 @@ function install_page_hook() {
902
916
  if (typeof window !== "undefined" && !hooked_hide) {
903
917
  hooked_hide = true;
904
918
  addEventListener("pagehide", () => {
919
+ leaving = true;
905
920
  try {
906
921
  const v = read_page();
907
922
  if (v && !v.nav) sessionStorage.setItem(LOAD_KEY + location.pathname, JSON.stringify(snapshot_of(v)));
908
923
  } catch {}
909
924
  });
925
+ addEventListener("pageshow", () => {
926
+ leaving = false;
927
+ });
910
928
  }
911
929
  }
912
930
  const LOAD_KEY = "ogygia:devtools:load:";
913
931
  let hooked_hide = false;
932
+ /** The document is going (`pagehide`, until a `pageshow` brings it back): reads ask the server nothing. */
933
+ let leaving = false;
914
934
  /** the previous load's picture of THIS page, read once per document (before this load overwrites it) */
915
935
  let prev_load;
916
936
  function previous_load() {
@@ -954,4 +974,4 @@ function snapshot_of(v) {
954
974
  ...v.report.findings.some((f) => f.dev_compile && (f.code === "shift-cause" || f.code === "vital-cls")) ? { cls_dev: true } : {}
955
975
  };
956
976
  }
957
- export { cpu_of, failures, held_open, hole_batches, hole_failures, hole_waits, install_page_hook, last_nav, read_page, region_facts, since_nav, summary_lines, unmeasured };
977
+ export { cpu_of, failures, held_open, hole_batches, hole_failures, hole_waits, install_page_hook, last_nav, prebundled_packages, read_page, region_facts, since_nav, summary_lines, unmeasured, vary_of };
@@ -1,10 +1,4 @@
1
- /**
2
- * The devtools' copy of the last SSR profile per page — the native Profiler tab writes it, the Lens
3
- * roster and the island detail read it (joined by fingerprint, then by entry), so a server cost sits
4
- * next to the browser's numbers for the same island. Kept SLIM: the profiler's JSON answer is
5
- * hundreds of KB; the dock needs the score, the forecast, the findings, the islands, the top
6
- * components and the calls. The slim copy also survives a reload (sessionStorage, best effort).
7
- */
1
+ import { Scope } from "@ogygia/machine";
8
2
  export interface SlimIsland {
9
3
  name: string;
10
4
  fp: string | null;
@@ -155,9 +149,10 @@ export interface SlimProfile {
155
149
  }
156
150
  /**
157
151
  * Profile a same-origin path on the server, now (the profiler's `/page?format=json`; a 409 = the
158
- * recorder is busy: wait and ask again). Stores the slim copy and returns it.
152
+ * recorder is busy: wait and ask again, `fetch_when_free`). Stores the slim copy and returns it;
153
+ * `null` once `owner` (the tab that asked) ended first.
159
154
  */
160
- export declare function run_profile(path: string, runs?: number, base?: string): Promise<SlimProfile>;
155
+ export declare function run_profile(path: string, runs: number, base: string, owner: Scope): Promise<SlimProfile | null>;
161
156
  /**
162
157
  * A record of the profiler's JSON answer, untyped on the wire (and possibly from another version):
163
158
  * read deep and tolerant (`r.since?.score?.a`), with every value checked before use (`num`, `str`,
@@ -1,30 +1,35 @@
1
+ import { fetch_when_free } from "../profiler/busy.js";
2
+ import { STALE } from "@ogygia/machine";
1
3
  // src/devtools/profile-store.ts
2
4
  /**
5
+ * The devtools' copy of the last SSR profile per page — the native Profiler tab writes it, the Lens
6
+ * roster and the island detail read it (joined by fingerprint, then by entry), so a server cost sits
7
+ * next to the browser's numbers for the same island. Kept SLIM: the profiler's JSON answer is
8
+ * hundreds of KB; the dock needs the score, the forecast, the findings, the islands, the top
9
+ * components and the calls. The slim copy also survives a reload (sessionStorage, best effort).
10
+ */
11
+ /**
3
12
  * Profile a same-origin path on the server, now (the profiler's `/page?format=json`; a 409 = the
4
- * recorder is busy: wait and ask again). Stores the slim copy and returns it.
13
+ * recorder is busy: wait and ask again, `fetch_when_free`). Stores the slim copy and returns it;
14
+ * `null` once `owner` (the tab that asked) ended first.
5
15
  */
6
- async function run_profile(path, runs = 3, base = "/__profiler") {
16
+ async function run_profile(path, runs, base, owner) {
7
17
  const url = `${base}/page?p=${encodeURIComponent(path)}&runs=${runs}&format=json`;
8
- for (let tries = 0; tries < 30; tries++) {
9
- const r = await fetch(url, { headers: { accept: "application/json" } });
10
- if (r.status === 409) {
11
- await new Promise((ok) => setTimeout(ok, 2e3));
12
- continue;
13
- }
14
- if (!r.ok) {
15
- const text = await r.text();
16
- let said = text;
17
- try {
18
- const j = JSON.parse(text);
19
- said = typeof j.error === "string" ? j.error : typeof j.message === "string" ? j.message : text;
20
- } catch {}
21
- throw new Error(r.status === 503 ? said.slice(0, 600) : `the profiler answered ${r.status}: ${said.slice(0, 300)}`);
22
- }
23
- const p = slim_profile(await r.json());
24
- set_profile(p);
25
- return p;
18
+ const r = await fetch_when_free(owner, url, { headers: { accept: "application/json" } });
19
+ if (r === STALE) return null;
20
+ if (r.status === 409) throw new Error("another profile kept the recorder busy for two minutes");
21
+ if (!r.ok) {
22
+ const text = await r.text();
23
+ let said = text;
24
+ try {
25
+ const j = JSON.parse(text);
26
+ said = typeof j.error === "string" ? j.error : typeof j.message === "string" ? j.message : text;
27
+ } catch {}
28
+ throw new Error(r.status === 503 ? said.slice(0, 600) : `the profiler answered ${r.status}: ${said.slice(0, 300)}`);
26
29
  }
27
- throw new Error("another profile kept the recorder busy for a minute");
30
+ const p = slim_profile(await r.json());
31
+ set_profile(p);
32
+ return p;
28
33
  }
29
34
  const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
30
35
  const str = (v) => typeof v === "string" ? v : null;
@@ -13,6 +13,8 @@ export declare function session_waiting(): boolean;
13
13
  /** `button#buy.primary` — a short readable name for an element. */
14
14
  export declare function describe_el(el: Element | null | undefined): string;
15
15
  interface Recorder {
16
+ /** Ends this recording, once: a second call gets the first call's data, and touches nothing of
17
+ * a recording started after it. */
16
18
  stop(): Promise<SessionData$1>;
17
19
  started: number;
18
20
  }