@downdraft/engine 0.2.0 → 0.3.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 (62) hide show
  1. package/core/src/index.ts +2 -2
  2. package/core/src/platform/rpc.ts +4 -1
  3. package/core/src/render/game-renderer.ts +127 -64
  4. package/core/src/sab/grid-buffer.ts +55 -2
  5. package/core/src/sab/record-buffer.ts +48 -5
  6. package/core/src/sab/sim-channel.ts +181 -17
  7. package/core/src/sab/slot-buffer.ts +55 -1
  8. package/core/src/sab/types.ts +11 -0
  9. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  10. package/core/src/util/index.ts +1 -0
  11. package/core/src/util/session-timer.ts +23 -0
  12. package/core/src/worker/base-worker-host.ts +2 -1
  13. package/core/src/worker/buffer-sync.ts +20 -13
  14. package/core/src/worker/rpc.ts +54 -5
  15. package/core/src/worker/sim-worker-base.ts +5 -2
  16. package/core/src/worker/task-pool.ts +4 -0
  17. package/dist/.dd-manifest.json +861 -851
  18. package/dist/core/src/index.js +2 -2
  19. package/dist/core/src/platform/rpc.js +2 -1
  20. package/dist/core/src/render/game-renderer.js +125 -59
  21. package/dist/core/src/sab/grid-buffer.js +53 -1
  22. package/dist/core/src/sab/record-buffer.js +49 -5
  23. package/dist/core/src/sab/sim-channel.js +173 -17
  24. package/dist/core/src/sab/slot-buffer.js +54 -1
  25. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  26. package/dist/core/src/util/index.js +1 -0
  27. package/dist/core/src/util/session-timer.js +22 -0
  28. package/dist/core/src/worker/base-worker-host.js +2 -1
  29. package/dist/core/src/worker/buffer-sync.js +16 -9
  30. package/dist/core/src/worker/rpc.js +46 -8
  31. package/dist/core/src/worker/sim-worker-base.js +5 -2
  32. package/dist/core/src/worker/task-pool.js +5 -0
  33. package/dist/libraries/devtools/src/backend.js +35 -9
  34. package/dist/libraries/devtools/src/collectors.js +73 -22
  35. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  36. package/dist/modules/devtools/src/blitz/host.js +124 -30
  37. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  38. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  39. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  40. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  41. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  42. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  43. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  44. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  45. package/dist/modules/html-ui/src/host.js +6 -3
  46. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  47. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  48. package/libraries/devtools/src/backend.ts +38 -12
  49. package/libraries/devtools/src/collectors.ts +75 -22
  50. package/modules/devtools/src/blitz/fonts.ts +78 -0
  51. package/modules/devtools/src/blitz/host.ts +107 -31
  52. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  53. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  54. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  55. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  56. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  57. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  58. package/modules/devtools/src/blitz/theme.ts +26 -4
  59. package/modules/html-ui/src/doc-backend.ts +67 -4
  60. package/modules/html-ui/src/host.ts +11 -3
  61. package/modules/html-ui/src/protocol.ts +1 -1
  62. package/package.json +2 -2
@@ -44,6 +44,12 @@ export class DevtoolsBackend {
44
44
  providers = new Map();
45
45
  commandHandlers = new Map();
46
46
  globalCommandHandler = null;
47
+ /** Dynamically-allocated slots for provider names outside the fixed PANEL
48
+ * table — panelSlot() alone collapsed every unknown name onto slot 0
49
+ * ("console"), so a second game provider silently replaced the first. */
50
+ dynSlots = new Map();
51
+ dynSlotNames = new Map();
52
+ nextDynSlot = 100;
47
53
  lastProviderPush = new Map();
48
54
  providerIntervalMs = 1500;
49
55
  /** When non-null, the cadence loop only auto-refreshes this provider
@@ -178,37 +184,57 @@ export class DevtoolsBackend {
178
184
  this.emit("console.clear", {});
179
185
  }
180
186
  // ── Providers / commands ──
187
+ /** Resolve a panel id to its slot. Names outside the fixed PANEL table
188
+ * get a stable dynamically-allocated slot so bespoke game providers can
189
+ * coexist instead of colliding on slot 0. */
190
+ slotFor(panel) {
191
+ if (typeof panel === "number")
192
+ return panel;
193
+ const fixed = PANEL[panel];
194
+ if (fixed !== undefined)
195
+ return fixed;
196
+ let s = this.dynSlots.get(panel);
197
+ if (s === undefined) {
198
+ s = this.nextDynSlot++;
199
+ this.dynSlots.set(panel, s);
200
+ this.dynSlotNames.set(s, panel);
201
+ }
202
+ return s;
203
+ }
204
+ nameFor(slot) {
205
+ return this.dynSlotNames.get(slot) ?? slotName(slot);
206
+ }
181
207
  registerProvider(panel, collect) {
182
- this.providers.set(panelSlot(panel), collect);
208
+ this.providers.set(this.slotFor(panel), collect);
183
209
  }
184
210
  registerCommandHandler(panel, handler) {
185
211
  if (panel === "*") {
186
212
  this.globalCommandHandler = handler;
187
213
  }
188
214
  else {
189
- this.commandHandlers.set(panelSlot(panel), handler);
215
+ this.commandHandlers.set(this.slotFor(panel), handler);
190
216
  }
191
217
  }
192
218
  /** Registered provider slots — the dynamic tab list for frontends. */
193
219
  providerSlots() {
194
- return [...this.providers.keys()].map((slot) => ({ slot, name: slotName(slot) }));
220
+ return [...this.providers.keys()].map((slot) => ({ slot, name: this.nameFor(slot) }));
195
221
  }
196
222
  /** Collect + emit one provider's snapshot immediately. */
197
223
  refreshPanel(panel) {
198
224
  const trace = [performance.now()];
199
- const slot = panelSlot(panel);
225
+ const slot = this.slotFor(panel);
200
226
  trace.push(performance.now());
201
227
  const collect = this.providers.get(slot);
202
228
  if (!collect)
203
229
  return;
204
230
  const push = (snap) => {
205
231
  if (snap)
206
- this.emit("snapshot", { panel: slotName(slot), slot, snap });
232
+ this.emit("snapshot", { panel: this.nameFor(slot), slot, snap });
207
233
  this.lastProviderPush.set(slot, performance.now());
208
234
  };
209
235
  const fail = (err) => {
210
236
  this.emit("snapshot", {
211
- panel: slotName(slot), slot,
237
+ panel: this.nameFor(slot), slot,
212
238
  snap: { status: "error", statusMsg: String(err), sections: [] },
213
239
  });
214
240
  this.lastProviderPush.set(slot, performance.now());
@@ -231,7 +257,7 @@ export class DevtoolsBackend {
231
257
  const total = trace[4] - trace[0];
232
258
  if (total > 10) {
233
259
  const seg = `pre=${(trace[2] - trace[0]).toFixed(1)} collect=${(trace[3] - trace[2]).toFixed(1)} emit=${(trace[4] - trace[3]).toFixed(1)}`;
234
- this.diagWarn(`slow provider ${slotName(slot)}: ${total.toFixed(1)}ms [${seg}]`);
260
+ this.diagWarn(`slow provider ${this.nameFor(slot)}: ${total.toFixed(1)}ms [${seg}]`);
235
261
  }
236
262
  }
237
263
  dispatchCommand(cmd) {
@@ -374,7 +400,7 @@ export class DevtoolsBackend {
374
400
  return collectThreads(this.ctx, [...this.threadEvals.keys()]);
375
401
  case "snapshot": {
376
402
  const panel = params.panel;
377
- const slot = typeof panel === "number" ? panel : panelSlot(panel);
403
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel));
378
404
  const collect = this.providers.get(slot);
379
405
  if (!collect)
380
406
  return { status: "unsupported", statusMsg: `no provider for ${String(panel)}`, sections: [] };
@@ -388,7 +414,7 @@ export class DevtoolsBackend {
388
414
  }
389
415
  case "command": {
390
416
  const panel = params.panel;
391
- const slot = typeof panel === "number" ? panel : panelSlot(panel ?? "console");
417
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel ?? "console"));
392
418
  this.dispatchCommand({ panel: slot, action: String(params.action ?? ""), payload: String(params.payload ?? "") });
393
419
  return true;
394
420
  }
@@ -72,31 +72,81 @@ export async function collectThreads(ctx, evalThreadIds) {
72
72
  });
73
73
  return threads;
74
74
  }
75
- /** Scene-graph tree (label + ctor + child counts). */
75
+ function sceneInspectorApi() {
76
+ const g = globalThis;
77
+ return (g.window?.__sceneInspector ?? g.__sceneInspector ?? null);
78
+ }
79
+ /** Scene-graph tree (label + ctor + child counts). Prefers a live
80
+ * `gameScene.stage` walk; falls back to the SceneInspector's scene store
81
+ * (`__sceneInspector.getSceneTree`) so games without a DOM-like stage still
82
+ * get a populated tree — with stable nodeIds for pick/details. */
76
83
  export function collectSceneTree(ctx) {
77
84
  const stage = ctx.gameScene?.stage;
78
- if (!stage)
85
+ if (stage) {
86
+ const nodes = [];
87
+ let idCounter = 1;
88
+ const collect = (node, parentId, depth) => {
89
+ const id = idCounter++;
90
+ // Prefer `label` (some scene-graph hosts warn on `name` access); fall
91
+ // back to `name` for hosts that only set that.
92
+ const nodeLabel = typeof node.label === "string" ? node.label
93
+ : typeof node.name === "string" ? node.name : "";
94
+ const ctorName = node.constructor?.name ?? "Node";
95
+ nodes.push({
96
+ id, parentId, depth,
97
+ childCount: node.children?.length ?? 0,
98
+ kind: 0,
99
+ label: nodeLabel || ctorName,
100
+ detail: nodeLabel ? ctorName : "",
101
+ nodeId: typeof node.id === "string" ? node.id : undefined,
102
+ });
103
+ node.children?.forEach((child) => collect(child, id, depth + 1));
104
+ };
105
+ collect(stage, -1, 0);
106
+ return nodes;
107
+ }
108
+ // Scene-store fallback — the same snapshot the inspector's getSceneTree
109
+ // exposes. Nodes are flat with id/parentId links; walk roots → children.
110
+ try {
111
+ const snap = sceneInspectorApi()?.getSceneTree?.();
112
+ const storeNodes = snap?.nodes;
113
+ if (!Array.isArray(storeNodes) || !storeNodes.length)
114
+ return [];
115
+ const byId = new Map(storeNodes.map((n) => [n.id, n]));
116
+ const roots = storeNodes.filter((n) => n.parentId === null || !byId.has(n.parentId));
117
+ const rows = [];
118
+ const visited = new Set();
119
+ let idCounter = 1;
120
+ const walk = (n, parentId, depth) => {
121
+ if (visited.has(n.id))
122
+ return; // corrupted parent/child links — don't loop
123
+ visited.add(n.id);
124
+ const id = idCounter++;
125
+ const p = n.position;
126
+ rows.push({
127
+ id, parentId, depth,
128
+ childCount: n.children?.length ?? 0,
129
+ kind: 0,
130
+ label: String(n.name ?? n.id),
131
+ detail: `${n.type ?? "node"}${Array.isArray(p) ? ` (${p.map((v) => (+v).toFixed(1)).join(", ")})` : ""}`,
132
+ nodeId: String(n.id),
133
+ });
134
+ for (const cid of n.children ?? []) {
135
+ const c = byId.get(cid);
136
+ if (c)
137
+ walk(c, id, depth + 1);
138
+ }
139
+ };
140
+ roots.forEach((n) => walk(n, -1, 0));
141
+ // Orphans (every node has a parent but no root resolves) — still show them.
142
+ for (const n of storeNodes)
143
+ if (!visited.has(n.id))
144
+ walk(n, -1, 0);
145
+ return rows;
146
+ }
147
+ catch {
79
148
  return [];
80
- const nodes = [];
81
- let idCounter = 1;
82
- const collect = (node, parentId, depth) => {
83
- const id = idCounter++;
84
- // Prefer `label` (some scene-graph hosts warn on `name` access); fall
85
- // back to `name` for hosts that only set that.
86
- const nodeLabel = typeof node.label === "string" ? node.label
87
- : typeof node.name === "string" ? node.name : "";
88
- const ctorName = node.constructor?.name ?? "Node";
89
- nodes.push({
90
- id, parentId, depth,
91
- childCount: node.children?.length ?? 0,
92
- kind: 0,
93
- label: nodeLabel || ctorName,
94
- detail: nodeLabel ? ctorName : "",
95
- });
96
- node.children?.forEach((child) => collect(child, id, depth + 1));
97
- };
98
- collect(stage, -1, 0);
99
- return nodes;
149
+ }
100
150
  }
101
151
  /** DOM tree: scene-graph stage or ECS entities depending on mode. */
102
152
  export function collectDomTree(ctx, mode) {
@@ -122,6 +172,7 @@ export function collectDomTree(ctx, mode) {
122
172
  id, parentId: -1, depth: 0, childCount: 0, kind: 1,
123
173
  label: `Entity ${ent.idx}`,
124
174
  detail: `type=${entityType} pos=(${x.toFixed(1)},${y.toFixed(1)},${z.toFixed(1)})`,
175
+ nodeId: `ent-${ent.idx}`,
125
176
  });
126
177
  });
127
178
  }
@@ -0,0 +1,75 @@
1
+ // ============================================================================
2
+ // fonts.ts — bind the host's real system fonts into the devtools dock doc.
3
+ //
4
+ // The OSR rasterizer ships a single embedded DejaVu Sans as the fallback for
5
+ // every generic family — legible, but `monospace` resolves to it too, so the
6
+ // console/REPL/tree read as proportional sans. Same fix as cruiser's
7
+ // libraries/app-shell/fonts.ts: resolve the user's actual sans + mono faces
8
+ // via fontconfig (fc-match -f %{file}), register them as `ui://` blobs on the
9
+ // HtmlUiHost before mount, and emit @font-face rules the dock stylesheet
10
+ // binds ahead of the generic fallbacks. Any failure (no fc-match, non-Linux
11
+ // host, unreadable file) leaves the embedded fallback in place.
12
+ // ============================================================================
13
+ import { execFileSync } from "node:child_process";
14
+ import { readFileSync } from "node:fs";
15
+ // Blitz-dom only fetches a @font-face source whose format is detectable —
16
+ // either an explicit format() hint or a file extension on the url — so the
17
+ // urls carry the resolved file's real extension.
18
+ const FACES = [
19
+ { family: "DevTools Sans", pattern: "sans-serif", urlName: "sans", weight: 400 },
20
+ { family: "DevTools Sans", pattern: "sans-serif:weight=bold", urlName: "sans-bold", weight: 700 },
21
+ { family: "DevTools Mono", pattern: "monospace", urlName: "mono", weight: 400 },
22
+ { family: "DevTools Mono", pattern: "monospace:weight=bold", urlName: "mono-bold", weight: 700 },
23
+ ];
24
+ /** `fc-match -f %{file}` for a fontconfig pattern; null when fc-match is
25
+ * absent or the match isn't a font file Blitz can load. */
26
+ function fcFontFile(pattern) {
27
+ try {
28
+ const p = execFileSync("fc-match", ["-f", "%{file}", pattern], { timeout: 5000 }).toString().trim();
29
+ return /\.(ttf|otf|woff2?)$/i.test(p) ? p : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function fontExt(path) {
36
+ return path.match(/\.(ttf|otf|woff2?)$/i)?.[0] ?? ".ttf";
37
+ }
38
+ /**
39
+ * Register system sans + mono faces as `ui://` resources and return the
40
+ * @font-face CSS to prepend to the dock stylesheet. Returns "" when nothing
41
+ * registered — the stylesheet's named families then fail to resolve and the
42
+ * generic fallbacks (embedded DejaVu) apply.
43
+ *
44
+ * Call before mount — the doc backend applies resources synchronously and in
45
+ * order, so the first doc resolve sees them.
46
+ */
47
+ export function registerDevtoolsFonts(sink) {
48
+ try {
49
+ // Both weights of a family are required: a 700 rule pointing at the
50
+ // regular file keeps bold text in-family instead of falling back.
51
+ const files = new Map();
52
+ for (const f of FACES)
53
+ files.set(f.urlName, fcFontFile(f.pattern) ?? "");
54
+ if (!files.get("sans"))
55
+ return "";
56
+ if (!files.get("mono"))
57
+ files.set("mono", files.get("sans"));
58
+ if (!files.get("sans-bold"))
59
+ files.set("sans-bold", files.get("sans"));
60
+ if (!files.get("mono-bold"))
61
+ files.set("mono-bold", files.get("mono"));
62
+ let css = "";
63
+ for (const f of FACES) {
64
+ const file = files.get(f.urlName);
65
+ const url = `ui://devtools-fonts/${f.urlName}${fontExt(file)}`;
66
+ sink.registerResource(url, readFileSync(file));
67
+ css += `@font-face { font-family:'${f.family}'; font-weight:${f.weight}; src:url('${url}'); }\n`;
68
+ }
69
+ return css;
70
+ }
71
+ catch {
72
+ // fc-match absent / unreadable font file — the embedded fallback applies.
73
+ return "";
74
+ }
75
+ }
@@ -13,11 +13,13 @@
13
13
  import { createLogger } from "@downdraft/engine";
14
14
  import { CdpBridge, DevtoolsBackend } from "@downdraft/engine/libraries/devtools";
15
15
  import { HtmlUiHost } from "@downdraft/engine/modules/html-ui";
16
+ import { registerDevtoolsFonts } from "./fonts.js";
16
17
  import { ConsolePanel } from "./panels/console.js";
17
18
  import { ElementsPanel } from "./panels/elements.js";
18
19
  import { GpuPanel } from "./panels/gpu.js";
19
20
  import { PerformancePanel } from "./panels/performance.js";
20
21
  import { SnapshotPanel } from "./panels/snapshot.js";
22
+ import { esc } from "./panels/types.js";
21
23
  import { DEVTOOLS_CSS } from "./theme.js";
22
24
  const log = createLogger("info");
23
25
  const MIN_DOCK = 120;
@@ -42,6 +44,9 @@ export class BlitzDevtoolsHost {
42
44
  panelCtx;
43
45
  lastPanelRender = -1e9;
44
46
  lastSlowLog = 0;
47
+ /** @font-face css from registerDevtoolsFonts — "" means "embedded font
48
+ * fallback", null means "not yet attempted". */
49
+ fontCss = null;
45
50
  constructor(opts) {
46
51
  this.opts = opts;
47
52
  this.surfaceW = opts.surface.clientWidth;
@@ -113,19 +118,42 @@ export class BlitzDevtoolsHost {
113
118
  show() {
114
119
  if (!this._ready || this.visible || this.disposed)
115
120
  return;
116
- this.panel = this.opts.ui.mount(this.shellHtml(), {
117
- id: "devtools",
118
- rect: this.dockRect(),
119
- // Above game overlays — menus/loading screens shouldn't bury the dock.
120
- z: 1000,
121
- scale: 1.5,
122
- interactive: true,
123
- // Devtools UI doesn't need vsync-rate raster — cap it so mutation
124
- // bursts (console streams, snapshot refreshes) coalesce into ~66ms
125
- // rasters instead of one raster per message.
126
- maxFps: 15,
127
- onEvent: (ev) => this.handleDocEvent(ev),
128
- });
121
+ // Register real system fonts once per host — ui:// resources persist on
122
+ // the surface across dock remounts, and must land before the doc mounts
123
+ // so the first resolve sees them (embedded DejaVu fallback otherwise).
124
+ if (this.fontCss === null) {
125
+ const ui = this.opts.ui;
126
+ const rr = ui.registerResource?.bind(ui)
127
+ ?? (ui.host ? ui.host.registerResource.bind(ui.host) : undefined);
128
+ this.fontCss = rr ? registerDevtoolsFonts({ registerResource: rr }) : "";
129
+ }
130
+ try {
131
+ this.panel = this.opts.ui.mount(this.shellHtml(), {
132
+ id: "devtools",
133
+ rect: this.dockRect(),
134
+ // Above game overlays — menus/loading screens shouldn't bury the dock.
135
+ z: 1000,
136
+ // Native-res raster: the vendored glifo/FreeType path grid-fits hinted
137
+ // glyph stems to whole pixels — supersample factors >1 just blur that
138
+ // back out on downscale (and cost 2.25x the texture memory).
139
+ scale: 1,
140
+ // Ease wheel detents at pump cadence instead of jumping per event.
141
+ smoothWheel: true,
142
+ interactive: true,
143
+ // Devtools UI doesn't need vsync-rate raster — cap it so mutation
144
+ // bursts (console streams, snapshot refreshes) coalesce into ~66ms
145
+ // rasters instead of one raster per message.
146
+ maxFps: 15,
147
+ onEvent: (ev) => this.handleDocEvent(ev),
148
+ });
149
+ }
150
+ catch (err) {
151
+ // Mount fails when the ui worker is dead — stay hidden so the next F12
152
+ // retries cleanly instead of wedging on a half-mounted doc.
153
+ this.panel = null;
154
+ log.error("blitz-devtools-host", `dock mount failed: ${err}`);
155
+ return;
156
+ }
129
157
  this.syncProviders();
130
158
  this.renderTabs();
131
159
  this.activate(this.activeId);
@@ -150,8 +178,19 @@ export class BlitzDevtoolsHost {
150
178
  const t1 = performance.now();
151
179
  if (!this.visible)
152
180
  return;
153
- this.syncProviders();
154
- this.flush();
181
+ // Guarded like backend.update() above — a throwing panel render can't be
182
+ // allowed to kill the game frame hook that drives this pump.
183
+ try {
184
+ this.syncProviders();
185
+ this.flush();
186
+ }
187
+ catch (err) {
188
+ const tn = performance.now();
189
+ if (tn - this.lastSlowLog > 1000) {
190
+ this.lastSlowLog = tn;
191
+ log.warn("blitz-devtools-host", `panel render error: ${err}`);
192
+ }
193
+ }
155
194
  const t2 = performance.now();
156
195
  // ≤1/sec — these diagnostics used to feed the console panel's logger
157
196
  // sink, and each row forced another dock re-render: a self-feeding
@@ -204,11 +243,13 @@ export class BlitzDevtoolsHost {
204
243
  }
205
244
  // ── Dock layout ──
206
245
  dockRect() {
207
- const h = Math.min(this.dockH, this.surfaceH - 60);
208
- return { x: 0, y: this.surfaceH - h, w: this.surfaceW, h };
246
+ // A tiny surface (or a pathological resize event) must not produce a
247
+ // negative height — keep at least enough room for the tab strip.
248
+ const h = Math.max(48, Math.min(this.dockH, this.surfaceH - 60));
249
+ return { x: 0, y: Math.max(0, this.surfaceH - h), w: this.surfaceW, h };
209
250
  }
210
251
  shellHtml() {
211
- return `<html><head><style>${DEVTOOLS_CSS}</style></head><body>
252
+ return `<html><head><style>${this.fontCss ?? ""}${DEVTOOLS_CSS}</style></head><body>
212
253
  <div id="dt-root">
213
254
  <div id="dt-grip" title="drag to resize"></div>
214
255
  <div id="dt-chrome">
@@ -224,7 +265,10 @@ export class BlitzDevtoolsHost {
224
265
  renderTabs() {
225
266
  if (!this.panel)
226
267
  return;
227
- const tabs = [...this.panels.values()].map((p) => `<span class="tab${p.id === this.activeId ? " active" : ""}" data-action="dt.tab" data-tab="${p.id}">${p.title}</span>`).join("");
268
+ // esc() both interpolations — provider names feed panel ids/titles and
269
+ // arrive as raw strings (a game registering "x\"><img" would otherwise
270
+ // inject markup into the tab strip).
271
+ const tabs = [...this.panels.values()].map((p) => `<span class="tab${p.id === this.activeId ? " active" : ""}" data-action="dt.tab" data-tab="${esc(p.id)}">${esc(p.title)}</span>`).join("");
228
272
  this.panel.setInnerHtml("#dt-tabs", `<span class="brand">downdraft</span>${tabs}<span class="spacer"></span>
229
273
  <span class="hint">docked · F12</span><span id="dt-close" data-action="dt.close">✕</span>`);
230
274
  this.tabsDirty = false;
@@ -235,13 +279,28 @@ export class BlitzDevtoolsHost {
235
279
  this.activeId = id;
236
280
  const p = this.panels.get(id);
237
281
  this.renderTabs();
238
- this.panel.setInnerHtml("#dt-top", p.renderTop?.() ?? "");
239
- this.panel.setInnerHtml("#dt-bottom", p.renderBottom?.() ?? "");
282
+ try {
283
+ this.panel.setInnerHtml("#dt-top", p.renderTop?.() ?? "");
284
+ this.panel.setInnerHtml("#dt-bottom", p.renderBottom?.() ?? "");
285
+ }
286
+ catch (err) {
287
+ log.warn("blitz-devtools-host", `panel ${id} chrome render error: ${err}`);
288
+ }
240
289
  p.dirty = true;
241
290
  p.shellDirty = false;
242
- this.panel.setInnerHtml("#dt-body", p.renderBody());
243
- p.afterRender?.();
244
- p.activate?.();
291
+ this.panel.setInnerHtml("#dt-body", this.safeBody(p));
292
+ try {
293
+ p.afterRender?.();
294
+ }
295
+ catch (err) {
296
+ log.warn("blitz-devtools-host", `panel ${id} afterRender error: ${err}`);
297
+ }
298
+ try {
299
+ p.activate?.();
300
+ }
301
+ catch (err) {
302
+ log.warn("blitz-devtools-host", `panel ${id} activate error: ${err}`);
303
+ }
245
304
  // Only the snapshot tab being viewed gets periodic provider refreshes;
246
305
  // fixed panels suspend auto-refresh entirely (manual Refresh still works).
247
306
  this.backend.providerWatch = p.providerSlot ?? -1;
@@ -307,10 +366,32 @@ export class BlitzDevtoolsHost {
307
366
  }
308
367
  if (p.dirty) {
309
368
  p.dirty = false;
310
- if (p.flushDom?.() !== true) {
311
- this.panel.setInnerHtml("#dt-body", p.renderBody());
369
+ let handled = false;
370
+ try {
371
+ handled = p.flushDom?.() === true;
312
372
  }
313
- p.afterRender?.();
373
+ catch (err) {
374
+ log.warn("blitz-devtools-host", `panel ${p.id} flushDom error: ${err}`);
375
+ }
376
+ if (!handled)
377
+ this.panel.setInnerHtml("#dt-body", this.safeBody(p));
378
+ try {
379
+ p.afterRender?.();
380
+ }
381
+ catch (err) {
382
+ log.warn("blitz-devtools-host", `panel ${p.id} afterRender error: ${err}`);
383
+ }
384
+ }
385
+ }
386
+ /** renderBody that can't take the dock down — the error surfaces in-panel
387
+ * (and the next push retries since dirty just flips back on). */
388
+ safeBody(p) {
389
+ try {
390
+ return p.renderBody();
391
+ }
392
+ catch (err) {
393
+ log.warn("blitz-devtools-host", `panel ${p.id} renderBody error: ${err}`);
394
+ return `<div class="status-banner error">panel render error: ${esc(String(err))}</div>`;
314
395
  }
315
396
  }
316
397
  setStatus(text) {
@@ -345,8 +426,10 @@ export class BlitzDevtoolsHost {
345
426
  return;
346
427
  }
347
428
  }
429
+ // Left-click only — routing actions on contextmenu meant a right-click
430
+ // on a tab/button fired it instead of just focusing.
348
431
  const action = ev.d?.action;
349
- if ((ev.t === "click" || ev.t === "contextmenu") && action) {
432
+ if (ev.t === "click" && action) {
350
433
  if (action === "dt.tab") {
351
434
  const id = ev.d.tab;
352
435
  if (id)
@@ -357,11 +440,21 @@ export class BlitzDevtoolsHost {
357
440
  this.hide();
358
441
  return;
359
442
  }
360
- this.panels.get(this.activeId)?.onAction?.(ev.d, ev);
443
+ try {
444
+ this.panels.get(this.activeId)?.onAction?.(ev.d, ev);
445
+ }
446
+ catch (err) {
447
+ log.warn("blitz-devtools-host", `panel action error: ${err}`);
448
+ }
361
449
  return;
362
450
  }
363
451
  // Everything else (input, keydown/up, scroll, focus…) → active panel.
364
- this.panels.get(this.activeId)?.onEvent?.(ev);
452
+ try {
453
+ this.panels.get(this.activeId)?.onEvent?.(ev);
454
+ }
455
+ catch (err) {
456
+ log.warn("blitz-devtools-host", `panel event error: ${err}`);
457
+ }
365
458
  }
366
459
  }
367
460
  /**
@@ -387,5 +480,6 @@ export function createSelfHostedDevtoolsUi(ctx) {
387
480
  host,
388
481
  mount: (markup, spec) => host.mount({ ...spec, html: markup }),
389
482
  docCaps: () => host.docCaps,
483
+ registerResource: (url, bytes) => host.registerResource(url, bytes),
390
484
  };
391
485
  }
@@ -101,7 +101,7 @@ export class ConsolePanel {
101
101
  onBackendEvent(event, data) {
102
102
  if (event === "console") {
103
103
  const e = data;
104
- this.rows.push({ text: e.text ?? "", severity: e.severity ?? 0, thread: e.thread ?? "main", seq: this.seq++ });
104
+ this.rows.push({ text: String(e?.text ?? ""), severity: e?.severity ?? 0, thread: e?.thread ?? "main", seq: this.seq++ });
105
105
  if (this.rows.length > MAX_ROWS)
106
106
  this.rows.splice(0, this.rows.length - MAX_ROWS);
107
107
  this.dirty = true;
@@ -113,15 +113,24 @@ export class ConsolePanel {
113
113
  }
114
114
  else if (event === "threads") {
115
115
  const list = data ?? [];
116
- this.threads = [{ id: "main", name: "main", kind: 0 }, ...list.filter((t) => t.id !== "main")];
117
- if (!this.threads.some((t) => t.id === this.replThread))
116
+ const next = [{ id: "main", name: "main", kind: 0 }, ...list.filter((t) => t.id !== "main")];
117
+ // Thread chips live in the bottom chrome — flag it so the row
118
+ // actually re-renders when a worker appears or disappears.
119
+ const changed = next.length !== this.threads.length
120
+ || next.some((t, i) => t.id !== this.threads[i].id || t.name !== this.threads[i].name);
121
+ this.threads = next;
122
+ if (!this.threads.some((t) => t.id === this.replThread)) {
118
123
  this.replThread = "main";
124
+ this.shellDirty = true;
125
+ }
126
+ if (changed)
127
+ this.shellDirty = true;
119
128
  }
120
129
  }
121
130
  onAction(data, _ev) {
122
131
  const act = data.action;
123
132
  if (act === "console.clear") {
124
- void this.ctx.call("console.clear");
133
+ void this.ctx.call("console.clear").catch(() => { });
125
134
  }
126
135
  else if (act === "console.sev") {
127
136
  this.minSev = (this.minSev + 1) % SEV_NAMES.length;
@@ -171,8 +180,11 @@ export class ConsolePanel {
171
180
  this.replValue = this.history[--this.histIdx];
172
181
  this.ctx.setValue("[data-dt=repl]", this.replValue);
173
182
  }
174
- else if (ev.k === "ArrowDown" && this.histIdx < this.history.length - 1) {
175
- this.replValue = this.history[++this.histIdx];
183
+ else if (ev.k === "ArrowDown" && this.histIdx >= 0 && this.histIdx < this.history.length) {
184
+ // Stepping past the newest entry restores the empty input —
185
+ // previously the last history item was unreachable-to-leave.
186
+ this.histIdx++;
187
+ this.replValue = this.histIdx < this.history.length ? this.history[this.histIdx] : "";
176
188
  this.ctx.setValue("[data-dt=repl]", this.replValue);
177
189
  }
178
190
  }
@@ -205,7 +217,7 @@ export class ConsolePanel {
205
217
  else {
206
218
  const r = res?.result;
207
219
  this.addRow({
208
- text: typeof r === "string" ? r : JSON.stringify(r, null, 2),
220
+ text: typeof r === "string" ? r : (r === undefined ? "undefined" : JSON.stringify(r, null, 2)),
209
221
  severity: 0, thread: this.replThread,
210
222
  });
211
223
  }