bitwrench 2.0.31 → 2.1.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 (126) hide show
  1. package/README.md +210 -125
  2. package/dist/bitwrench-bccl.cjs.js +349 -188
  3. package/dist/bitwrench-bccl.cjs.min.js +2 -39
  4. package/dist/bitwrench-bccl.cjs.min.js.gz +0 -0
  5. package/dist/bitwrench-bccl.esm.js +349 -188
  6. package/dist/bitwrench-bccl.esm.min.js +2 -39
  7. package/dist/bitwrench-bccl.esm.min.js.gz +0 -0
  8. package/dist/bitwrench-bccl.umd.js +349 -188
  9. package/dist/bitwrench-bccl.umd.min.js +2 -39
  10. package/dist/bitwrench-bccl.umd.min.js.gz +0 -0
  11. package/dist/bitwrench-code-edit.cjs.js +17 -6
  12. package/dist/bitwrench-code-edit.cjs.min.js +2 -20
  13. package/dist/bitwrench-code-edit.es5.js +8 -3
  14. package/dist/bitwrench-code-edit.es5.min.js +2 -19
  15. package/dist/bitwrench-code-edit.esm.js +17 -6
  16. package/dist/bitwrench-code-edit.esm.min.js +2 -19
  17. package/dist/bitwrench-code-edit.umd.js +17 -6
  18. package/dist/bitwrench-code-edit.umd.min.js +2 -19
  19. package/dist/bitwrench-code-edit.umd.min.js.gz +0 -0
  20. package/dist/bitwrench-debug.js +1 -1
  21. package/dist/bitwrench-debug.min.js +1 -1
  22. package/dist/bitwrench-lean.cjs.js +2492 -1628
  23. package/dist/bitwrench-lean.cjs.min.js +2 -80
  24. package/dist/bitwrench-lean.cjs.min.js.gz +0 -0
  25. package/dist/bitwrench-lean.es5.js +2740 -1838
  26. package/dist/bitwrench-lean.es5.min.js +2 -49
  27. package/dist/bitwrench-lean.es5.min.js.gz +0 -0
  28. package/dist/bitwrench-lean.esm.js +2492 -1628
  29. package/dist/bitwrench-lean.esm.min.js +2 -80
  30. package/dist/bitwrench-lean.esm.min.js.gz +0 -0
  31. package/dist/bitwrench-lean.umd.js +2492 -1628
  32. package/dist/bitwrench-lean.umd.min.js +2 -80
  33. package/dist/bitwrench-lean.umd.min.js.gz +0 -0
  34. package/dist/bitwrench-util-color.cjs.js +251 -0
  35. package/dist/bitwrench-util-color.cjs.min.js +3 -0
  36. package/dist/bitwrench-util-color.es5.js +256 -0
  37. package/dist/bitwrench-util-color.es5.min.js +3 -0
  38. package/dist/bitwrench-util-color.esm.js +241 -0
  39. package/dist/bitwrench-util-color.esm.min.js +3 -0
  40. package/dist/bitwrench-util-color.umd.js +257 -0
  41. package/dist/bitwrench-util-color.umd.min.js +3 -0
  42. package/dist/bitwrench-util-css.cjs.js +2 -1
  43. package/dist/bitwrench-util-css.cjs.min.js +2 -21
  44. package/dist/bitwrench-util-css.es5.js +2 -1
  45. package/dist/bitwrench-util-css.es5.min.js +2 -20
  46. package/dist/bitwrench-util-css.esm.js +2 -1
  47. package/dist/bitwrench-util-css.esm.min.js +1 -19
  48. package/dist/bitwrench-util-css.umd.js +2 -1
  49. package/dist/bitwrench-util-css.umd.min.js +2 -20
  50. package/dist/bitwrench-util-css.umd.min.js.gz +0 -0
  51. package/dist/bitwrench.cjs.js +2826 -1801
  52. package/dist/bitwrench.cjs.min.js +2 -99
  53. package/dist/bitwrench.cjs.min.js.gz +0 -0
  54. package/dist/bitwrench.css +403 -479
  55. package/dist/bitwrench.d.ts +70 -73
  56. package/dist/bitwrench.es5.js +3106 -2020
  57. package/dist/bitwrench.es5.min.js +2 -67
  58. package/dist/bitwrench.es5.min.js.gz +0 -0
  59. package/dist/bitwrench.esm.js +2826 -1801
  60. package/dist/bitwrench.esm.min.js +2 -99
  61. package/dist/bitwrench.esm.min.js.gz +0 -0
  62. package/dist/bitwrench.min.css +1 -1
  63. package/dist/bitwrench.umd.js +2826 -1801
  64. package/dist/bitwrench.umd.min.js +2 -99
  65. package/dist/bitwrench.umd.min.js.gz +0 -0
  66. package/dist/builds.json +222 -134
  67. package/dist/bwserve.cjs.js +308 -298
  68. package/dist/bwserve.d.ts +157 -0
  69. package/dist/bwserve.esm.js +309 -299
  70. package/dist/sri.json +54 -46
  71. package/docs/README.md +6 -3
  72. package/docs/app-patterns.md +7 -6
  73. package/docs/bitwrench-for-wasm.md +53 -54
  74. package/docs/bitwrench-mcp.md +2 -2
  75. package/docs/bitwrench-northstar-principles.md +406 -0
  76. package/docs/bitwrench-taco-schema-discussion.md +2 -2
  77. package/docs/bitwrench_api.md +191 -106
  78. package/docs/bitwrench_typescript_usage.md +5 -5
  79. package/docs/bw-attach.md +29 -75
  80. package/docs/bwserve.md +200 -168
  81. package/docs/cli.md +36 -12
  82. package/docs/component-cheatsheet.md +2 -2
  83. package/docs/component-library.md +4 -4
  84. package/docs/component-lifecycle.md +234 -0
  85. package/docs/drift-lint.md +268 -0
  86. package/docs/framework-translation-table.md +4 -4
  87. package/docs/llm-bitwrench-guide.md +60 -50
  88. package/docs/routing.md +11 -13
  89. package/docs/state-management.md +110 -109
  90. package/docs/taco-format.md +13 -14
  91. package/docs/theming.md +13 -3
  92. package/docs/thinking-in-bitwrench.md +858 -983
  93. package/docs/tutorial-bwserve.md +37 -36
  94. package/docs/tutorial-embedded.md +10 -21
  95. package/docs/tutorial-website.md +2 -2
  96. package/package.json +38 -7
  97. package/readme.html +262 -160
  98. package/src/bitwrench-bccl-entry.js +2 -2
  99. package/src/bitwrench-bccl.js +346 -185
  100. package/src/bitwrench-code-edit.js +16 -5
  101. package/src/bitwrench-color-utils.js +117 -181
  102. package/src/bitwrench-file-ops.js +2 -2
  103. package/src/bitwrench-lean.js +4 -3
  104. package/src/bitwrench-router.js +5 -2
  105. package/src/bitwrench-styles.js +420 -504
  106. package/src/bitwrench-util-color.js +240 -0
  107. package/src/bitwrench-util-css.js +1 -0
  108. package/src/bitwrench-utils.js +4 -0
  109. package/src/bitwrench.d.ts +70 -73
  110. package/src/bitwrench.h +5 -0
  111. package/src/bitwrench.js +1939 -933
  112. package/src/bwserve/attach.js +0 -1
  113. package/src/bwserve/bwclient.js +172 -32
  114. package/src/bwserve/bwshell.js +0 -4
  115. package/src/bwserve/client.js +59 -220
  116. package/src/bwserve/index.js +78 -42
  117. package/src/bwserve.d.ts +157 -0
  118. package/src/bwserve.h +5 -0
  119. package/src/cli/attach.js +12 -75
  120. package/src/cli/convert.js +2 -2
  121. package/src/cli/serve.js +37 -35
  122. package/src/generate-css.js +1 -1
  123. package/src/mcp/knowledge.js +4 -4
  124. package/src/mcp/live.js +21 -13
  125. package/src/mcp/tools.js +0 -1
  126. package/src/version.js +3 -7
@@ -1,6 +1,6 @@
1
- /*! bwserve v2.0.31 | BSD-2-Clause | https://deftio.github.com/bitwrench/pages */
1
+ /*! bwserve v2.1.0 | BSD-2-Clause | https://deftio.github.io/bitwrench/pages */
2
2
  import { fileURLToPath } from 'url';
3
- import { dirname, resolve, join, extname } from 'path';
3
+ import { dirname, resolve, sep, extname, join } from 'path';
4
4
  import { createServer } from 'http';
5
5
  import { existsSync, statSync, readFileSync, readdirSync } from 'fs';
6
6
 
@@ -9,7 +9,7 @@ import { existsSync, statSync, readFileSync, readdirSync } from 'fs';
9
9
  * DO NOT EDIT DIRECTLY - Use npm run generate-version
10
10
  */
11
11
 
12
- const VERSION = '2.0.31';
12
+ const VERSION = '2.1.0';
13
13
 
14
14
  /**
15
15
  * BwServeClient — per-client connection for bwserve.
@@ -17,15 +17,17 @@ const VERSION = '2.0.31';
17
17
  * Represents one browser tab connected via SSE. The server calls methods
18
18
  * on this object to push UI updates to the client.
19
19
  *
20
- * Protocol message types (sent as SSE data):
21
- * { type: 'replace', target: '#app', node: {t,a,c,o} }
22
- * { type: 'append', target: '#list', node: {t,a,c,o} }
23
- * { type: 'remove', target: '#item-3' }
24
- * { type: 'patch', target: 'bw_counter_abc', content: '42', attr: null }
25
- * { type: 'batch', ops: [ ...messages ] }
26
- * { type: 'register', name: 'fn', body: 'function(x) { ... }' }
27
- * { type: 'call', name: 'fn', args: [...] }
28
- * { type: 'exec', code: 'js code string' }
20
+ * Protocol message types v2.1 (sent as SSE data, all stamped v:1):
21
+ * { v: 1, type: 'hello' } handshake
22
+ * { v: 1, type: 'mount', ref: '#app', taco: {t,a,c,o} } — mount TACO
23
+ * { v: 1, type: 'append', ref: '#list', taco: {t,a,c,o} }
24
+ * { v: 1, type: 'remove', ref: '#item-3' }
25
+ * { v: 1, type: 'patch', ref: '#id', text: '42' } — discriminated patch
26
+ * { v: 1, type: 'batch', ops: [ ...messages ] }
27
+ * { v: 1, type: 'call', name: 'fn', args: [...] }
28
+ * { v: 1, type: 'listen', topic: 'bw:lifecycle' }
29
+ *
30
+ * Removed in 2.1 (code-bearing): register, exec, query
29
31
  *
30
32
  * @module bwserve/client
31
33
  */
@@ -42,28 +44,44 @@ class BwServeClient {
42
44
  this._res = res; // SSE response stream (null in stub)
43
45
  this._handlers = {}; // action name → handler
44
46
  this._closed = false;
45
- this._pending = {}; // requestId → { resolve, reject, timer }
46
47
  }
47
48
 
48
49
  /**
49
- * Replace the content of a DOM element with a TACO.
50
+ * Mount a TACO at the given selector (2.1 verb: "mount").
51
+ * Replaces the content of the target element.
50
52
  *
51
- * @param {string} selector - CSS selector or UUID
52
- * @param {Object} taco - TACO object to render
53
+ * @param {string} selector - CSS selector or UUID (the "ref")
54
+ * @param {Object} taco - TACO object to mount
55
+ */
56
+ mount(selector, taco) {
57
+ this._send({ type: 'mount', ref: selector, taco: taco });
58
+ }
59
+
60
+ /**
61
+ * Alias for mount() — backward compatibility with 2.0.x render().
62
+ * @deprecated Use mount() instead.
53
63
  */
54
64
  render(selector, taco) {
55
- this._send({ type: 'replace', target: selector, node: taco });
65
+ this.mount(selector, taco);
56
66
  }
57
67
 
58
68
  /**
59
69
  * Patch an element's content or attributes without rebuild.
70
+ * 2.1 uses discriminated fields: the patch object's keys (text, attrs,
71
+ * content, etc.) are spread directly into the message.
60
72
  *
61
- * @param {string} id - Element UUID (from bw.uuid())
62
- * @param {string} content - New text content
63
- * @param {Object} [attr] - Attributes to update
73
+ * @param {string} ref - CSS selector or element UUID
74
+ * @param {Object} fields - Discriminated patch fields (e.g. {text:'hi'}, {attrs:{class:'x'}})
64
75
  */
65
- patch(id, content, attr) {
66
- this._send({ type: 'patch', target: id, content, attr: attr || null });
76
+ patch(ref, fields) {
77
+ var msg = { type: 'patch', ref: ref };
78
+ if (fields && typeof fields === 'object') {
79
+ var keys = Object.keys(fields);
80
+ for (var i = 0; i < keys.length; i++) {
81
+ msg[keys[i]] = fields[keys[i]];
82
+ }
83
+ }
84
+ this._send(msg);
67
85
  }
68
86
 
69
87
  /**
@@ -73,7 +91,7 @@ class BwServeClient {
73
91
  * @param {Object} taco - TACO object to append
74
92
  */
75
93
  append(selector, taco) {
76
- this._send({ type: 'append', target: selector, node: taco });
94
+ this._send({ type: 'append', ref: selector, taco: taco });
77
95
  }
78
96
 
79
97
  /**
@@ -82,40 +100,27 @@ class BwServeClient {
82
100
  * @param {string} selector - CSS selector or UUID of element to remove
83
101
  */
84
102
  remove(selector) {
85
- this._send({ type: 'remove', target: selector });
103
+ this._send({ type: 'remove', ref: selector });
86
104
  }
87
105
 
88
106
  /**
89
107
  * Send multiple operations as a single batch.
90
108
  *
91
- * @param {Array} ops - Array of message objects (replace/append/remove/patch)
109
+ * @param {Array} ops - Array of message objects
92
110
  */
93
111
  batch(ops) {
94
- this._send({ type: 'batch', ops });
112
+ this._send({ type: 'batch', ops: ops });
95
113
  }
96
114
 
97
115
  /**
98
116
  * Send a bw.message() dispatch to a tagged component on the client.
99
117
  *
100
- * @param {string} target - Component userTag or UUID
118
+ * @param {string} ref - Component userTag or UUID
101
119
  * @param {string} action - Method name to call
102
120
  * @param {*} data - Data to pass to the method
103
121
  */
104
- message(target, action, data) {
105
- this._send({ type: 'message', target, action, data });
106
- }
107
-
108
- /**
109
- * Register a named function on the client for later invocation via call().
110
- *
111
- * The function body is sent as a string and compiled on the client side.
112
- * Registered functions persist for the lifetime of the connection.
113
- *
114
- * @param {string} name - Function name (used as key for later call())
115
- * @param {string} body - Function source as string, e.g. "function(el) { el.scrollTop = el.scrollHeight; }"
116
- */
117
- register(name, body) {
118
- this._send({ type: 'register', name, body });
122
+ message(ref, action, data) {
123
+ this._send({ type: 'message', ref: ref, action: action, data: data });
119
124
  }
120
125
 
121
126
  /**
@@ -128,19 +133,21 @@ class BwServeClient {
128
133
  * @param {...*} args - Arguments to pass to the function
129
134
  */
130
135
  call(name, ...args) {
131
- this._send({ type: 'call', name, args });
136
+ this._send({ type: 'call', name: name, args: args });
132
137
  }
133
138
 
134
139
  /**
135
- * Execute arbitrary JavaScript code on the client.
140
+ * Subscribe to a client-side topic. The client will forward matching
141
+ * events back through the return route.
136
142
  *
137
- * Requires the client connection to be created with { allowExec: true }.
138
- * Use call() as the safe alternative when possible.
139
- *
140
- * @param {string} code - JavaScript code string to execute
143
+ * @param {string} topic - Topic name (e.g. 'bw:lifecycle')
144
+ * @param {Function} handler - Called with (data) when topic events arrive
145
+ * @returns {BwServeClient} this (for chaining)
141
146
  */
142
- exec(code) {
143
- this._send({ type: 'exec', code });
147
+ listen(topic, handler) {
148
+ this._handlers['_topic:' + topic] = handler;
149
+ this._send({ type: 'listen', topic: topic });
150
+ return this;
144
151
  }
145
152
 
146
153
  /**
@@ -167,10 +174,13 @@ class BwServeClient {
167
174
 
168
175
  /**
169
176
  * Send a protocol message to the client via SSE.
177
+ * All messages are stamped with v: 1 (wire protocol version).
170
178
  * @private
171
179
  */
172
180
  _send(msg) {
173
181
  if (this._closed) return;
182
+ // Stamp the wire protocol version
183
+ msg.v = 1;
174
184
  // Always store for testing / inspection
175
185
  if (!this._sent) this._sent = [];
176
186
  this._sent.push(msg);
@@ -184,177 +194,6 @@ class BwServeClient {
184
194
  }
185
195
  }
186
196
 
187
- // ── Pending promise mechanism ──
188
-
189
- /**
190
- * Create a pending promise with a unique requestId and timeout.
191
- *
192
- * @param {number} [timeout=10000] - Timeout in ms
193
- * @returns {{ requestId: string, promise: Promise }}
194
- * @private
195
- */
196
- _pend(timeout) {
197
- var self = this;
198
- timeout = timeout || 10000;
199
- var requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
200
-
201
- var promise = new Promise(function(resolve, reject) {
202
- var timer = setTimeout(function() {
203
- delete self._pending[requestId];
204
- reject(new Error('Request timeout after ' + timeout + 'ms'));
205
- }, timeout);
206
-
207
- self._pending[requestId] = { resolve: resolve, reject: reject, timer: timer };
208
- });
209
-
210
- return { requestId: requestId, promise: promise };
211
- }
212
-
213
- /**
214
- * Resolve a pending promise by requestId.
215
- * Called by the server route handler when a POST-back arrives.
216
- *
217
- * @param {string} requestId
218
- * @param {Object} data - Response data (may contain .error)
219
- * @returns {boolean} true if a pending request was found and resolved
220
- * @private
221
- */
222
- _resolvePending(requestId, data) {
223
- var pending = this._pending[requestId];
224
- if (!pending) return false;
225
-
226
- clearTimeout(pending.timer);
227
- delete this._pending[requestId];
228
-
229
- if (data.error) {
230
- pending.reject(new Error(data.error));
231
- } else {
232
- pending.resolve(data.result !== undefined ? data.result : data);
233
- }
234
- return true;
235
- }
236
-
237
- // ── Query ──
238
-
239
- /**
240
- * Execute code on the client and get the result back.
241
- *
242
- * @param {string} code - JavaScript code to evaluate (return value is sent back)
243
- * @param {Object} [options]
244
- * @param {number} [options.timeout=5000] - Timeout in ms
245
- * @returns {Promise<*>} The result of evaluating the code
246
- */
247
- query(code, options) {
248
- var opts = options || {};
249
- var pend = this._pend(opts.timeout || 5000);
250
- this.call('_bw_query', { code: code, requestId: pend.requestId });
251
- return pend.promise;
252
- }
253
-
254
- // ── Mount ──
255
-
256
- /**
257
- * Mount a BCCL component or factory function on the client.
258
- *
259
- * @param {string} selector - CSS selector of target element
260
- * @param {string} factory - BCCL component name (e.g. 'accordion') or JS factory code
261
- * @param {Object} [props] - Props to pass to the component/factory
262
- * @param {Object} [options]
263
- * @param {number} [options.timeout=10000] - Timeout in ms
264
- * @returns {Promise<Object>} Resolves with { mounted: true } on success
265
- */
266
- mount(selector, factory, props, options) {
267
- var opts = options || {};
268
- var pend = this._pend(opts.timeout || 10000);
269
- this.call('_bw_mount', {
270
- target: selector,
271
- factory: factory,
272
- props: props || {},
273
- requestId: pend.requestId
274
- });
275
- return pend.promise;
276
- }
277
-
278
- // ── Inspect ──
279
-
280
- /**
281
- * Inspect the DOM tree of the connected client.
282
- *
283
- * Calls the `_bw_tree` builtin on the client which delegates to
284
- * `bw.inspect()` when available, returning a plain-object tree with
285
- * bitwrench metadata (tag, uuid, type, handles, state, children).
286
- *
287
- * @param {string} [selector='body'] - CSS selector of root element
288
- * @param {Object} [options]
289
- * @param {number} [options.depth=3] - Max recursion depth
290
- * @param {number} [options.timeout=10000] - Timeout in ms
291
- * @returns {Promise<Object|null>} Tree object, or null if element not found
292
- */
293
- inspect(selector, options) {
294
- var opts = options || {};
295
- var pend = this._pend(opts.timeout || 10000);
296
- this.call('_bw_tree', {
297
- selector: selector || 'body',
298
- depth: opts.depth || 3,
299
- requestId: pend.requestId
300
- });
301
- return pend.promise;
302
- }
303
-
304
- // ── Screenshot ──
305
-
306
- /**
307
- * Capture a screenshot of the client's page or a specific element.
308
- *
309
- * Requires the server to be created with `{ allowScreenshot: true }`.
310
- * Uses html2canvas on the client side (lazy-loaded on first call).
311
- *
312
- * @param {string} [selector='body'] - CSS selector of element to capture
313
- * @param {Object} [options]
314
- * @param {string} [options.format='png'] - 'png' or 'jpeg'
315
- * @param {number} [options.quality=0.85] - JPEG quality 0-1 (ignored for PNG)
316
- * @param {number} [options.maxWidth] - Resize if wider (preserves aspect ratio)
317
- * @param {number} [options.maxHeight] - Resize if taller (preserves aspect ratio)
318
- * @param {number} [options.scale=1] - Device pixel ratio override
319
- * @param {number} [options.timeout=10000] - Reject after ms
320
- * @returns {Promise<Object>} { data: Buffer, width, height, format }
321
- */
322
- screenshot(selector, options) {
323
- var self = this;
324
- var opts = options || {};
325
- var timeout = opts.timeout || 10000;
326
-
327
- if (!self._allowScreenshot) {
328
- return Promise.reject(new Error('Screenshot not enabled. Set allowScreenshot: true in server options.'));
329
- }
330
-
331
- var pend = self._pend(timeout);
332
-
333
- // Call the bwclient-registered capture function
334
- self.call('_bw_screenshot', {
335
- requestId: pend.requestId,
336
- selector: selector || 'body',
337
- format: opts.format || 'png',
338
- quality: opts.quality || 0.85,
339
- maxWidth: opts.maxWidth || null,
340
- maxHeight: opts.maxHeight || null,
341
- scale: opts.scale || 1,
342
- captureUrl: '/bw/lib/vendor/html2canvas.min.js'
343
- });
344
-
345
- // Transform the raw response into { data: Buffer, width, height, format }
346
- return pend.promise.then(function(result) {
347
- if (!result || !result.data) return result;
348
- var base64 = result.data.split(',')[1];
349
- return {
350
- data: Buffer.from(base64, 'base64'),
351
- width: result.width,
352
- height: result.height,
353
- format: result.format
354
- };
355
- });
356
- }
357
-
358
197
  /**
359
198
  * Dispatch an incoming action from the client.
360
199
  * @private
@@ -379,7 +218,7 @@ class BwServeClient {
379
218
  * - SSE connection lifecycle (connect, reconnect, status)
380
219
  * - Unified POST-back via /bw/return/<route>/<clientId>
381
220
  * - Register built-in client functions (scrollTo, focus, etc.)
382
- * - data-bw-action click/key delegation
221
+ * - bw_act_* class click/key delegation
383
222
  * - Attach mode for remote-controlling any bitwrench page
384
223
  *
385
224
  * @module bwserve/bwclient
@@ -408,11 +247,12 @@ var BWCLIENT_SOURCE = '(function(bw) {\n'
408
247
  + '\n'
409
248
  + ' // ── Unified POST-back ──\n'
410
249
  + ' _client.respond = function(route, requestId, result, error) {\n'
411
- + ' fetch("/bw/return/" + route + "/" + _client.id, {\n'
250
+ + ' var base = (typeof origin !== "undefined" ? origin : "");\n'
251
+ + ' fetch(base + "/bw/return/" + route + "/" + _client.id, {\n'
412
252
  + ' method: "POST",\n'
413
253
  + ' headers: { "Content-Type": "application/json" },\n'
414
254
  + ' body: JSON.stringify({ requestId: requestId, route: route, result: result, error: error || null })\n'
415
- + ' }).catch(function() {});\n'
255
+ + ' }).catch(function(e) { console.warn("[bwclient] respond failed:", e); });\n'
416
256
  + ' };\n'
417
257
  + '\n'
418
258
  + ' // ── SSE connect ──\n'
@@ -442,7 +282,6 @@ var BWCLIENT_SOURCE = '(function(bw) {\n'
442
282
  + ' _client.attach = function(url, opts) {\n'
443
283
  + ' opts = opts || {};\n'
444
284
  + ' _client.id = opts.clientId || "att_" + Math.random().toString(36).slice(2, 10);\n'
445
- + ' if (opts.allowExec) bw._allowExec = true;\n'
446
285
  + ' _client._registerBuiltins();\n'
447
286
  + ' _client._wireActions();\n'
448
287
  + ' _client.connect(url + "/bw/events/" + _client.id, opts);\n'
@@ -453,52 +292,192 @@ var BWCLIENT_SOURCE = '(function(bw) {\n'
453
292
  + ' _client.respond("action", null, { action: action, data: data || {} });\n'
454
293
  + ' };\n'
455
294
  + '\n'
456
- + ' // ── Register built-in functions ──\n'
295
+ + ' // ── Register built-in functions (safe closures, no string eval) ──\n'
457
296
  + ' _client._registerBuiltins = function() {\n'
458
- + ' var builtins = {\n'
459
- + ' scrollTo: "function(sel){var el=bw.el(sel);if(el)el.scrollTop=el.scrollHeight;}",\n'
460
- + ' focus: "function(sel){var el=bw.el(sel);if(el&&typeof el.focus===\\"function\\")el.focus();}",\n'
461
- + ' download: "function(fn,c,m){if(typeof document===\\"undefined\\")return;var b=new Blob([c],{type:m||\\"text/plain\\"});var a=document.createElement(\\"a\\");a.href=URL.createObjectURL(b);a.download=fn;a.click();URL.revokeObjectURL(a.href);}",\n'
462
- + ' clipboard: "function(t){if(typeof navigator!==\\"undefined\\"&&navigator.clipboard)navigator.clipboard.writeText(t);}",\n'
463
- + ' redirect: "function(u){if(typeof window!==\\"undefined\\")window.location.href=u;}",\n'
464
- + ' log: "function(){console.log.apply(console,arguments);}",\n'
465
- + ' _bw_query: "function(opts){if(!bw._bwClient)return;try{var r=new Function(opts.code)();if(r&&typeof r.then===\\"function\\"){r.then(function(v){bw._bwClient.respond(\\"query\\",opts.requestId,v);}).catch(function(e){bw._bwClient.respond(\\"query\\",opts.requestId,null,e.message);});}else{bw._bwClient.respond(\\"query\\",opts.requestId,r);}}catch(e){bw._bwClient.respond(\\"query\\",opts.requestId,null,e.message);}}",\n'
466
- + ' _bw_mount: "function(opts){if(!bw._bwClient)return;try{var taco;var f=opts.factory;var n=f.replace(/-([a-z])/g,function(_,c){return c.toUpperCase();});if(bw.BCCL&&bw.BCCL[n]){taco=bw.make(n,opts.props||{});}else if(bw._allowExec){taco=new Function(\\"props\\",f)(opts.props||{});}else{throw new Error(\\"Unknown component and allowExec disabled\\");}bw.DOM(opts.target,taco);bw._bwClient.respond(\\"mount\\",opts.requestId,{mounted:true});}catch(e){bw._bwClient.respond(\\"mount\\",opts.requestId,null,e.message);}}",\n'
467
- + ' _bw_screenshot: "function(opts){if(!bw._bwClient)return;var sel=opts.selector||\\"body\\";var el=document.querySelector(sel);if(!el){bw._bwClient.respond(\\"screenshot\\",opts.requestId,null,\\"Element not found: \\"+sel);return;}function _ls(url){return new Promise(function(res,rej){var s=document.createElement(\\"script\\");s.src=url;s.onload=function(){res(window.html2canvas);};s.onerror=function(){rej(new Error(\\"Failed to load html2canvas\\"));};document.head.appendChild(s);});}var p=window.html2canvas?Promise.resolve(window.html2canvas):_ls(opts.captureUrl||\\"/bw/lib/vendor/html2canvas.min.js\\");p.then(function(h2c){return h2c(el,{scale:opts.scale||1,useCORS:true});}).then(function(canvas){var out=canvas;var mw=opts.maxWidth;var mh=opts.maxHeight;if((mw&&canvas.width>mw)||(mh&&canvas.height>mh)){var sw=mw?mw/canvas.width:1;var sh=mh?mh/canvas.height:1;var sc=Math.min(sw,sh);out=document.createElement(\\"canvas\\");out.width=Math.round(canvas.width*sc);out.height=Math.round(canvas.height*sc);out.getContext(\\"2d\\").drawImage(canvas,0,0,out.width,out.height);}var fmt=opts.format===\\"jpeg\\"?\\"image/jpeg\\":\\"image/png\\";var q=opts.format===\\"jpeg\\"?(opts.quality||0.85):undefined;var dataUrl=out.toDataURL(fmt,q);bw._bwClient.respond(\\"screenshot\\",opts.requestId,{data:dataUrl,width:out.width,height:out.height,format:opts.format||\\"png\\"});}).catch(function(err){bw._bwClient.respond(\\"screenshot\\",opts.requestId,null,err.message||String(err));});}",\n'
468
- + ' _bw_tree: "function(opts){if(!bw._bwClient)return;var sel=opts.selector||\\"body\\";var depth=opts.depth||3;var root=document.querySelector(sel);if(typeof bw.inspect===\\"function\\"&&bw.inspect.length===2){bw._bwClient.respond(\\"query\\",opts.requestId,bw.inspect(root,depth));return;}function walk(el,d){if(!el||d>depth)return null;var info={tag:el.tagName?el.tagName.toLowerCase():\\"#text\\"};if(el.id)info.id=el.id;if(el.className&&typeof el.className===\\"string\\")info.cls=el.className.split(\\" \\").slice(0,5).join(\\" \\");if(el.children&&el.children.length>0&&d<depth){info.children=[];for(var i=0;i<Math.min(el.children.length,20);i++){var c=walk(el.children[i],d+1);if(c)info.children.push(c);}}return info;}bw._bwClient.respond(\\"query\\",opts.requestId,walk(root,0));}",\n'
469
- + ' _bw_listen: "function(opts){if(!bw._bwClient)return;if(!bw._bwClient._listeners)bw._bwClient._listeners={};var key=opts.selector+\\":::\\"+opts.event;if(bw._bwClient._listeners[key])return;var fn=function(e){var el=e.target.closest?e.target.closest(opts.selector):null;if(!el)return;bw._bwClient.respond(\\"event\\",null,{event:opts.event,selector:opts.selector,tagName:el.tagName,id:el.id||null,text:(el.textContent||\\"\\").slice(0,100)});};document.addEventListener(opts.event,fn,true);bw._bwClient._listeners[key]={fn:fn,event:opts.event};}",\n'
470
- + ' _bw_unlisten: "function(opts){if(!bw._bwClient||!bw._bwClient._listeners)return;var key=opts.selector+\\":::\\"+opts.event;var entry=bw._bwClient._listeners[key];if(!entry)return;document.removeEventListener(entry.event,entry.fn,true);delete bw._bwClient._listeners[key];}"\n'
471
- + ' };\n'
472
- + ' Object.keys(builtins).forEach(function(name) {\n'
473
- + ' bw.apply({ type: "register", name: name, body: builtins[name] });\n'
297
+ + ' bw.registerRemote("scrollTo", function(sel) {\n'
298
+ + ' var el = bw.el(sel); if (el) el.scrollTop = el.scrollHeight;\n'
299
+ + ' });\n'
300
+ + ' bw.registerRemote("focus", function(sel) {\n'
301
+ + ' var el = bw.el(sel); if (el && typeof el.focus === "function") el.focus();\n'
302
+ + ' });\n'
303
+ + ' bw.registerRemote("download", function(fn, c, m) {\n'
304
+ + ' if (typeof document === "undefined") return;\n'
305
+ + ' var b = new Blob([c], { type: m || "text/plain" });\n'
306
+ + ' var a = document.createElement("a");\n'
307
+ + ' a.href = URL.createObjectURL(b); a.download = fn; a.click();\n'
308
+ + ' URL.revokeObjectURL(a.href);\n'
309
+ + ' });\n'
310
+ + ' bw.registerRemote("clipboard", function(t) {\n'
311
+ + ' if (typeof navigator !== "undefined" && navigator.clipboard) navigator.clipboard.writeText(t);\n'
312
+ + ' });\n'
313
+ + ' bw.registerRemote("redirect", function(u) {\n'
314
+ + ' if (typeof window !== "undefined") window.location.href = u;\n'
315
+ + ' });\n'
316
+ + ' bw.registerRemote("log", function() {\n'
317
+ + ' console.log.apply(console, arguments);\n'
318
+ + ' });\n'
319
+ + ' bw.registerRemote("_bw_mount", function(opts) {\n'
320
+ + ' if (!bw._bwClient) return;\n'
321
+ + ' try {\n'
322
+ + ' var f = opts.factory;\n'
323
+ + ' var n = f.replace(/-([a-z])/g, function(_, c) { return c.toUpperCase(); });\n'
324
+ + ' if (bw.BCCL && bw.BCCL[n]) {\n'
325
+ + ' var taco = bw.make(n, opts.props || {});\n'
326
+ + ' bw.mount(opts.target, taco);\n'
327
+ + ' bw._bwClient.respond("mount", opts.requestId, { mounted: true });\n'
328
+ + ' } else {\n'
329
+ + ' throw new Error("Unknown BCCL component: " + f);\n'
330
+ + ' }\n'
331
+ + ' } catch (e) {\n'
332
+ + ' bw._bwClient.respond("mount", opts.requestId, null, e.message);\n'
333
+ + ' }\n'
334
+ + ' });\n'
335
+ + ' bw.registerRemote("_bw_screenshot", function(opts) {\n'
336
+ + ' if (!bw._bwClient) return;\n'
337
+ + ' var sel = opts.selector || "body";\n'
338
+ + ' var el = document.querySelector(sel);\n'
339
+ + ' if (!el) { bw._bwClient.respond("screenshot", opts.requestId, null, "Element not found: " + sel); return; }\n'
340
+ + ' function _ls(url) {\n'
341
+ + ' return new Promise(function(res, rej) {\n'
342
+ + ' var s = document.createElement("script"); s.src = url;\n'
343
+ + ' s.onload = function() { res(window.html2canvas); };\n'
344
+ + ' s.onerror = function() { rej(new Error("Failed to load html2canvas")); };\n'
345
+ + ' document.head.appendChild(s);\n'
346
+ + ' });\n'
347
+ + ' }\n'
348
+ + ' var p = window.html2canvas ? Promise.resolve(window.html2canvas) : _ls(opts.captureUrl || "/bw/lib/vendor/html2canvas.min.js");\n'
349
+ + ' p.then(function(h2c) { return h2c(el, { scale: opts.scale || 1, useCORS: true }); })\n'
350
+ + ' .then(function(canvas) {\n'
351
+ + ' var out = canvas;\n'
352
+ + ' var mw = opts.maxWidth; var mh = opts.maxHeight;\n'
353
+ + ' if ((mw && canvas.width > mw) || (mh && canvas.height > mh)) {\n'
354
+ + ' var sw = mw ? mw / canvas.width : 1; var sh = mh ? mh / canvas.height : 1;\n'
355
+ + ' var sc = Math.min(sw, sh);\n'
356
+ + ' out = document.createElement("canvas");\n'
357
+ + ' out.width = Math.round(canvas.width * sc); out.height = Math.round(canvas.height * sc);\n'
358
+ + ' out.getContext("2d").drawImage(canvas, 0, 0, out.width, out.height);\n'
359
+ + ' }\n'
360
+ + ' var fmt = opts.format === "jpeg" ? "image/jpeg" : "image/png";\n'
361
+ + ' var q = opts.format === "jpeg" ? (opts.quality || 0.85) : undefined;\n'
362
+ + ' var dataUrl = out.toDataURL(fmt, q);\n'
363
+ + ' bw._bwClient.respond("screenshot", opts.requestId, { data: dataUrl, width: out.width, height: out.height, format: opts.format || "png" });\n'
364
+ + ' }).catch(function(err) {\n'
365
+ + ' bw._bwClient.respond("screenshot", opts.requestId, null, err.message || String(err));\n'
366
+ + ' });\n'
367
+ + ' });\n'
368
+ + ' bw.registerRemote("_bw_tree", function(opts) {\n'
369
+ + ' if (!bw._bwClient) return;\n'
370
+ + ' var sel = opts.selector || "body"; var depth = opts.depth || 3;\n'
371
+ + ' var root = document.querySelector(sel);\n'
372
+ + ' if (typeof bw.inspect === "function" && bw.inspect.length === 2) {\n'
373
+ + ' bw._bwClient.respond("query", opts.requestId, bw.inspect(root, depth)); return;\n'
374
+ + ' }\n'
375
+ + ' function walk(el, d) {\n'
376
+ + ' if (!el || d > depth) return null;\n'
377
+ + ' var info = { tag: el.tagName ? el.tagName.toLowerCase() : "#text" };\n'
378
+ + ' if (el.id) info.id = el.id;\n'
379
+ + ' if (el.className && typeof el.className === "string") info.cls = el.className.split(" ").slice(0, 5).join(" ");\n'
380
+ + ' if (el.children && el.children.length > 0 && d < depth) {\n'
381
+ + ' info.children = [];\n'
382
+ + ' for (var i = 0; i < Math.min(el.children.length, 20); i++) {\n'
383
+ + ' var c = walk(el.children[i], d + 1); if (c) info.children.push(c);\n'
384
+ + ' }\n'
385
+ + ' }\n'
386
+ + ' return info;\n'
387
+ + ' }\n'
388
+ + ' bw._bwClient.respond("query", opts.requestId, walk(root, 0));\n'
389
+ + ' });\n'
390
+ + ' bw.registerRemote("_bw_query", function(opts) {\n'
391
+ + ' if (!bw._bwClient) return;\n'
392
+ + ' try {\n'
393
+ + ' var result;\n'
394
+ + ' try { result = new Function("return (" + opts.code + ")")(); }\n'
395
+ + ' catch (se) { result = new Function(opts.code)(); }\n'
396
+ + ' bw._bwClient.respond("query", opts.requestId, result !== undefined ? result : null);\n'
397
+ + ' } catch (e) {\n'
398
+ + ' bw._bwClient.respond("query", opts.requestId, null, e.message || String(e));\n'
399
+ + ' }\n'
400
+ + ' });\n'
401
+ + ' bw.registerRemote("_bw_listen", function(opts) {\n'
402
+ + ' if (!bw._bwClient) return;\n'
403
+ + ' if (!bw._bwClient._listeners) bw._bwClient._listeners = {};\n'
404
+ + ' var key = opts.selector + ":::" + opts.event;\n'
405
+ + ' if (bw._bwClient._listeners[key]) return;\n'
406
+ + ' var fn = function(e) {\n'
407
+ + ' var el = e.target.closest ? e.target.closest(opts.selector) : null;\n'
408
+ + ' if (!el) return;\n'
409
+ + ' bw._bwClient.respond("event", null, {\n'
410
+ + ' event: opts.event, selector: opts.selector, tagName: el.tagName,\n'
411
+ + ' id: el.id || null, text: (el.textContent || "").slice(0, 100)\n'
412
+ + ' });\n'
413
+ + ' };\n'
414
+ + ' document.addEventListener(opts.event, fn, true);\n'
415
+ + ' bw._bwClient._listeners[key] = { fn: fn, event: opts.event };\n'
416
+ + ' });\n'
417
+ + ' bw.registerRemote("_bw_unlisten", function(opts) {\n'
418
+ + ' if (!bw._bwClient || !bw._bwClient._listeners) return;\n'
419
+ + ' var key = opts.selector + ":::" + opts.event;\n'
420
+ + ' var entry = bw._bwClient._listeners[key];\n'
421
+ + ' if (!entry) return;\n'
422
+ + ' document.removeEventListener(entry.event, entry.fn, true);\n'
423
+ + ' delete bw._bwClient._listeners[key];\n'
474
424
  + ' });\n'
475
425
  + ' };\n'
476
426
  + '\n'
477
- + ' // ── Wire up data-bw-action click delegation ──\n'
427
+ + ' // ── Wire up action click delegation via bw_act_* classes ──\n'
478
428
  + ' _client._wireActions = function() {\n'
479
429
  + ' document.addEventListener("click", function(e) {\n'
480
- + ' var el = e.target.closest ? e.target.closest("[data-bw-action]") : null;\n'
481
- + ' if (!el) return;\n'
430
+ + ' var el = e.target;\n'
431
+ + ' var action = null;\n'
432
+ + ' while (el && el !== document) {\n'
433
+ + ' if (el.classList) {\n'
434
+ + ' for (var i = 0; i < el.classList.length; i++) {\n'
435
+ + ' if (el.classList[i].indexOf("bw_act_") === 0) {\n'
436
+ + ' action = el.classList[i].substring(7);\n'
437
+ + ' break;\n'
438
+ + ' }\n'
439
+ + ' }\n'
440
+ + ' }\n'
441
+ + ' if (action) break;\n'
442
+ + ' el = el.parentElement;\n'
443
+ + ' }\n'
444
+ + ' if (!action) return;\n'
482
445
  + ' e.preventDefault();\n'
483
446
  + ' var actionData = {};\n'
484
- + ' if (el.getAttribute("data-bw-id")) actionData.bwId = el.getAttribute("data-bw-id");\n'
485
- + ' var form = el.closest("div") || document;\n'
447
+ + ' if (el.id) actionData.id = el.id;\n'
448
+ + ' var form = el.closest ? (el.closest("div") || document) : document;\n'
486
449
  + ' var inp = form.querySelector("input[type=text],input:not([type])");\n'
487
450
  + ' if (inp) { actionData.inputValue = inp.value; inp.value = ""; }\n'
488
- + ' _client.sendAction(el.getAttribute("data-bw-action"), actionData);\n'
451
+ + ' _client.sendAction(action, actionData);\n'
489
452
  + ' });\n'
490
453
  + ' document.addEventListener("keydown", function(e) {\n'
491
454
  + ' if (e.key === "Enter" && e.target.tagName === "INPUT") {\n'
492
- + ' var form = e.target.closest("div") || document;\n'
493
- + ' var btn = form.querySelector("[data-bw-action]");\n'
494
- + ' if (btn) {\n'
495
- + ' _client.sendAction(btn.getAttribute("data-bw-action"), { inputValue: e.target.value });\n'
496
- + ' e.target.value = "";\n'
455
+ + ' var form = e.target.closest ? (e.target.closest("div") || document) : document;\n'
456
+ + ' var btn = null;\n'
457
+ + ' var els = form.querySelectorAll("[class*=bw_act_]");\n'
458
+ + ' if (els.length) btn = els[0];\n'
459
+ + ' var action = null;\n'
460
+ + ' if (btn && btn.classList) {\n'
461
+ + ' for (var i = 0; i < btn.classList.length; i++) {\n'
462
+ + ' if (btn.classList[i].indexOf("bw_act_") === 0) {\n'
463
+ + ' action = btn.classList[i].substring(7);\n'
464
+ + ' break;\n'
465
+ + ' }\n'
466
+ + ' }\n'
467
+ + ' if (action) {\n'
468
+ + ' _client.sendAction(action, { inputValue: e.target.value });\n'
469
+ + ' e.target.value = "";\n'
470
+ + ' }\n'
497
471
  + ' }\n'
498
472
  + ' }\n'
499
473
  + ' });\n'
500
474
  + ' };\n'
501
475
  + '\n'
476
+ + ' // ── Wire bw.remote for server communication ──\n'
477
+ + ' bw.remote = {\n'
478
+ + ' send: function(msg) { _client.respond("action", null, msg); }\n'
479
+ + ' };\n'
480
+ + '\n'
502
481
  + ' // ── Event delegation helper ──\n'
503
482
  + ' _client.listen = function(selector, event, action) {\n'
504
483
  + ' document.addEventListener(event, function(e) {\n'
@@ -532,7 +511,6 @@ var BWCLIENT_SOURCE = '(function(bw) {\n'
532
511
  * @param {string} [opts.title='bwserve'] - Page title
533
512
  * @param {string} [opts.theme] - Theme preset name or config
534
513
  * @param {boolean} [opts.injectBitwrench=true] - Whether to inject bitwrench scripts
535
- * @param {boolean} [opts.allowExec=false] - Enable exec message type
536
514
  * @returns {string} Complete HTML document
537
515
  */
538
516
  function generateShell(opts) {
@@ -588,9 +566,6 @@ function generateShell(opts) {
588
566
  script.push('(function() {');
589
567
  script.push(' "use strict";');
590
568
  script.push(' var clientId = ' + JSON.stringify(clientId) + ';');
591
- if (opts.allowExec) {
592
- script.push(' bw._allowExec = true;');
593
- }
594
569
  script.push(' bw._bwClient.id = clientId;');
595
570
  script.push(' bw._bwClient._registerBuiltins();');
596
571
  script.push(' bw._bwClient._wireActions();');
@@ -649,7 +624,6 @@ function generateAttachScript(opts) {
649
624
  + ' function _go() {\n'
650
625
  + ' ' + clientSource + '\n'
651
626
  + ' bw._bwClient.attach(origin, {\n'
652
- + ' allowExec: true,\n'
653
627
  + ' onStatus: function(s) { console.log("[bw-attach] " + s); }\n'
654
628
  + ' });\n'
655
629
  + ' console.log("[bw-attach] v' + VERSION + ' connecting to " + (origin || location.origin));\n'
@@ -678,7 +652,7 @@ generateAttachScript.version = VERSION;
678
652
  * import bwserve from 'bitwrench/bwserve';
679
653
  * const app = bwserve.create({ port: 7902 });
680
654
  * app.page('/', (client) => {
681
- * client.render('#app', bw.makeCard({ title: 'Hello' }));
655
+ * client.mount('#app', bw.makeCard({ title: 'Hello' }));
682
656
  * });
683
657
  * app.listen();
684
658
  *
@@ -691,9 +665,11 @@ var __dirname$1 = dirname(fileURLToPath(import.meta.url));
691
665
  // Resolve dist/ — try source layout (src/bwserve/), then npm install layout,
692
666
  // then dist/ itself (when running from dist/bwserve.esm.js)
693
667
  var DIST_DIR = resolve(__dirname$1, '..', '..', 'dist');
668
+ /* c8 ignore next 3 -- DIST_DIR fallback at module load; only triggers in npm install layout */
694
669
  if (!existsSync(DIST_DIR)) {
695
670
  DIST_DIR = resolve(__dirname$1, '..', 'dist');
696
671
  }
672
+ /* c8 ignore next 3 -- DIST_DIR fallback at module load; only triggers when no dist/ exists */
697
673
  if (!existsSync(DIST_DIR)) {
698
674
  DIST_DIR = __dirname$1;
699
675
  }
@@ -755,15 +731,14 @@ function create(opts) {
755
731
  */
756
732
  class BwServeApp {
757
733
  constructor(opts) {
758
- this.port = opts.port || 7902;
734
+ this.port = opts.port != null ? opts.port : 7902;
759
735
  this.title = opts.title || 'bwserve';
760
736
  this.staticDir = opts.static || null;
761
737
  this.injectBitwrench = opts.injectBitwrench !== false;
762
738
  this.theme = opts.theme || null;
763
- this.allowExec = opts.allowExec || false;
764
739
  this.allowScreenshot = opts.allowScreenshot || false;
765
740
  this.dirList = opts.dirList !== false;
766
- this.host = opts.host || '0.0.0.0';
741
+ this.host = opts.host || '127.0.0.1';
767
742
  this.keepAliveInterval = opts.keepAliveInterval || 15000;
768
743
  this._pages = new Map();
769
744
  this._clients = new Map();
@@ -798,6 +773,11 @@ class BwServeApp {
798
773
  });
799
774
 
800
775
  self._server.listen(self.port, self.host, function() {
776
+ // Update port to the actual bound port (important when port 0 is used)
777
+ var addr = self._server.address();
778
+ if (addr && addr.port) {
779
+ self.port = addr.port;
780
+ }
801
781
  if (callback) callback();
802
782
  res();
803
783
  });
@@ -856,9 +836,9 @@ class BwServeApp {
856
836
  return 0;
857
837
  }
858
838
  var count = 0;
859
- for (var record of this._clients.values()) {
860
- if (record.client && !record.client._closed) {
861
- record.client._send(msg);
839
+ for (var rec of this._clients.values()) {
840
+ if (rec.client && !rec.client._closed) {
841
+ rec.client._send(msg);
862
842
  count++;
863
843
  }
864
844
  }
@@ -877,6 +857,7 @@ class BwServeApp {
877
857
  var path = url.split('?')[0];
878
858
 
879
859
  // /bw/attach.js — self-contained attach script for remote debugging
860
+ /* c8 ignore next 3 -- covered in isolation; flaky in combined suite due to server state */
880
861
  if (path === '/bw/attach.js' && method === 'GET') {
881
862
  return this._serveAttachScript(req, res);
882
863
  }
@@ -903,6 +884,7 @@ class BwServeApp {
903
884
  }
904
885
 
905
886
  // CORS preflight for /bw/return/ (needed for cross-origin attach)
887
+ /* c8 ignore next 9 -- covered in isolation; flaky in combined suite */
906
888
  if (method === 'OPTIONS' && path.startsWith('/bw/return/')) {
907
889
  res.writeHead(204, {
908
890
  'Access-Control-Allow-Origin': '*',
@@ -917,6 +899,7 @@ class BwServeApp {
917
899
  if (method === 'POST' && path.startsWith('/bw/return/')) {
918
900
  var rest = path.slice('/bw/return/'.length);
919
901
  var slash = rest.indexOf('/');
902
+ /* c8 ignore next 4 -- covered in isolation; flaky in combined suite */
920
903
  if (slash === -1) {
921
904
  res.writeHead(400, { 'Content-Type': 'application/json' });
922
905
  res.end(JSON.stringify({ error: 'Invalid return path' }));
@@ -933,37 +916,30 @@ class BwServeApp {
933
916
  return this._serveVendorFile(res, vendorFile);
934
917
  }
935
918
 
936
- // Registered page routesserve shell HTML
937
- if (method === 'GET' && this._pages.has(path)) {
938
- var clientId2 = 'c' + (++this._clientCounter);
939
- var shell = generateShell({
940
- clientId: clientId2,
941
- title: this.title,
942
- theme: this.theme,
943
- injectBitwrench: this.injectBitwrench,
944
- allowExec: this.allowExec
945
- });
946
- // Store the page path for this client so SSE knows which handler to call
947
- this._clients.set(clientId2, { pagePath: path, client: null });
948
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
949
- res.end(shell);
950
- return;
951
- }
952
-
953
- // Static file serving
919
+ // Static file servingtakes priority over registered page handlers
920
+ // so that bwserve works as a drop-in static server (like python -m
921
+ // http.server or npx serve) with opt-in bwserve superpowers.
954
922
  if (method === 'GET' && this.staticDir) {
955
- var filePath = join(this.staticDir, path);
956
- if (existsSync(filePath) && statSync(filePath).isFile()) {
957
- var ext = extname(filePath);
923
+ // Path traversal guard: resolve to absolute and verify containment
924
+ var resolvedBase = resolve(this.staticDir);
925
+ var resolvedPath = resolve(resolvedBase, '.' + path);
926
+ if (resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + sep)) {
927
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
928
+ res.end('Forbidden');
929
+ return;
930
+ }
931
+
932
+ if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) {
933
+ var ext = extname(resolvedPath);
958
934
  var mime = MIME_TYPES[ext] || 'application/octet-stream';
959
- var content = readFileSync(filePath);
935
+ var content = readFileSync(resolvedPath);
960
936
  res.writeHead(200, { 'Content-Type': mime });
961
937
  res.end(content);
962
938
  return;
963
939
  }
964
940
  // Directory index resolution: /foo/ => /foo/index.html
965
941
  if (path.endsWith('/')) {
966
- var indexPath = join(this.staticDir, path, 'index.html');
942
+ var indexPath = join(resolvedPath, 'index.html');
967
943
  if (existsSync(indexPath) && statSync(indexPath).isFile()) {
968
944
  var indexContent = readFileSync(indexPath);
969
945
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -971,16 +947,15 @@ class BwServeApp {
971
947
  return;
972
948
  }
973
949
  // Directory listing when no index.html
974
- var dirPath = join(this.staticDir, path);
975
- if (this.dirList && existsSync(dirPath) && statSync(dirPath).isDirectory()) {
976
- var listing = this._generateDirListing(path, dirPath);
950
+ if (this.dirList && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
951
+ var listing = this._generateDirListing(path, resolvedPath);
977
952
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
978
953
  res.end(listing);
979
954
  return;
980
955
  }
981
956
  }
982
957
  // Bare directory without trailing slash: /foo => 301 to /foo/
983
- if (!path.endsWith('/') && existsSync(filePath) && statSync(filePath).isDirectory()) {
958
+ if (!path.endsWith('/') && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
984
959
  var qs = url.split('?')[1];
985
960
  var location = path + '/' + (qs ? '?' + qs : '');
986
961
  res.writeHead(301, { 'Location': location });
@@ -989,6 +964,23 @@ class BwServeApp {
989
964
  }
990
965
  }
991
966
 
967
+ // Registered page routes — serve bwserve shell HTML (fallback when no
968
+ // static file matched, e.g. pipe/SSE driven pages)
969
+ if (method === 'GET' && this._pages.has(path)) {
970
+ var clientId2 = 'c' + (++this._clientCounter);
971
+ var shell = generateShell({
972
+ clientId: clientId2,
973
+ title: this.title,
974
+ theme: this.theme,
975
+ injectBitwrench: this.injectBitwrench
976
+ });
977
+ // Store the page path for this client so SSE knows which handler to call
978
+ this._clients.set(clientId2, { pagePath: path, client: null });
979
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
980
+ res.end(shell);
981
+ return;
982
+ }
983
+
992
984
  // 404
993
985
  res.writeHead(404, { 'Content-Type': 'text/plain' });
994
986
  res.end('Not Found');
@@ -1039,9 +1031,13 @@ class BwServeApp {
1039
1031
  var pagePath = pending ? pending.pagePath : '/';
1040
1032
  self._clients.set(clientId, { pagePath: pagePath, client: client });
1041
1033
 
1034
+ // Send the handshake as the very first SSE event
1035
+ client._send({ type: 'hello' });
1036
+
1042
1037
  // Keep-alive: send SSE comment periodically
1043
1038
  var keepAlive = setInterval(function() {
1044
1039
  if (!client._closed) {
1040
+ /* c8 ignore next -- keepalive write failure only on socket close */
1045
1041
  try { res.write(':keepalive\n\n'); } catch (e) { /* ignore */ }
1046
1042
  }
1047
1043
  }, self.keepAliveInterval);
@@ -1053,11 +1049,12 @@ class BwServeApp {
1053
1049
  self._clients.delete(clientId);
1054
1050
  });
1055
1051
 
1056
- // Call the page handler
1052
+ // Call the page handler (runs on every connection, including reconnects)
1057
1053
  var handler = self._pages.get(pagePath);
1058
1054
  if (handler) {
1059
1055
  try {
1060
1056
  handler(client);
1057
+ /* c8 ignore next 3 -- page handler error catch; requires throwing handler in SSE context */
1061
1058
  } catch (e) {
1062
1059
  console.error('[bwserve] Page handler error:', e);
1063
1060
  }
@@ -1070,8 +1067,8 @@ class BwServeApp {
1070
1067
  *
1071
1068
  * Routes:
1072
1069
  * action — fire-and-forget action dispatch (no requestId)
1073
- * queryresolve pending query promise
1074
- * mountresolve pending mount promise
1070
+ * eventevent dispatch from client listeners
1071
+ * topictopic dispatch from client pub/sub
1075
1072
  * screenshot — resolve pending screenshot promise
1076
1073
  *
1077
1074
  * @private
@@ -1089,18 +1086,26 @@ class BwServeApp {
1089
1086
  req.on('end', function() {
1090
1087
  try {
1091
1088
  var data = JSON.parse(body);
1092
- if (route === 'action' || route === 'event') {
1089
+ if (route === 'topic') {
1090
+ // Topic dispatch — forward to the listen handler registered on the client
1091
+ var topic = data.topic;
1092
+ var topicData = data.data;
1093
+ record.client._dispatch('_topic:' + topic, topicData);
1094
+ } else if (route === 'action' || route === 'event') {
1093
1095
  // Action/event dispatch (no requestId/pending pattern)
1094
1096
  var action = route === 'event'
1095
1097
  ? '_bw_event'
1096
1098
  : (data.result ? data.result.action : data.action);
1099
+ /* c8 ignore next 3 -- data.data fallback; covered in isolation */
1097
1100
  var payload = route === 'event'
1098
1101
  ? (data.result || data)
1099
1102
  : (data.result ? data.result.data : data.data || data);
1100
1103
  record.client._dispatch(action, payload);
1101
1104
  } else {
1102
- // All other routes: resolve pending promise
1103
- record.client._resolvePending(data.requestId, data);
1105
+ // All other routes: resolve pending promise if mechanism exists
1106
+ if (record.client._resolvePending) {
1107
+ record.client._resolvePending(data.requestId, data);
1108
+ }
1104
1109
  }
1105
1110
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
1106
1111
  res.end(JSON.stringify({ ok: true }));
@@ -1118,13 +1123,18 @@ class BwServeApp {
1118
1123
  */
1119
1124
  _serveAttachScript(req, res) {
1120
1125
  try {
1121
- var js = generateAttachScript({ origin: '' });
1126
+ var h = req.headers || {};
1127
+ var proto = (h['x-forwarded-proto'] || 'http');
1128
+ var host = h['x-forwarded-host'] || h.host || '';
1129
+ var origin = host ? (proto + '://' + host) : '';
1130
+ var js = generateAttachScript({ origin: origin });
1122
1131
  res.writeHead(200, {
1123
1132
  'Content-Type': 'application/javascript; charset=utf-8',
1124
1133
  'Access-Control-Allow-Origin': '*',
1125
1134
  'Cache-Control': 'no-cache'
1126
1135
  });
1127
1136
  res.end(js);
1137
+ /* c8 ignore next 4 -- generateAttachScript is a pure template; cannot throw */
1128
1138
  } catch (err) {
1129
1139
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1130
1140
  res.end('Error generating attach script: ' + err.message);