bitwrench 2.0.32 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +211 -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 +289 -282
  68. package/dist/bwserve.d.ts +157 -0
  69. package/dist/bwserve.esm.js +290 -283
  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 -161
  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 +59 -26
  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.32 | BSD-2-Clause | https://deftio.github.com/bitwrench/pages */
1
+ /*! bwserve v2.1.1 | 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.32';
12
+ const VERSION = '2.1.1';
13
13
 
14
14
  /**
15
15
  * BwServeClient — per-client connection for bwserve.
@@ -17,15 +17,17 @@ const VERSION = '2.0.32';
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' }));
@@ -937,18 +920,26 @@ class BwServeApp {
937
920
  // so that bwserve works as a drop-in static server (like python -m
938
921
  // http.server or npx serve) with opt-in bwserve superpowers.
939
922
  if (method === 'GET' && this.staticDir) {
940
- var filePath = join(this.staticDir, path);
941
- if (existsSync(filePath) && statSync(filePath).isFile()) {
942
- 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);
943
934
  var mime = MIME_TYPES[ext] || 'application/octet-stream';
944
- var content = readFileSync(filePath);
935
+ var content = readFileSync(resolvedPath);
945
936
  res.writeHead(200, { 'Content-Type': mime });
946
937
  res.end(content);
947
938
  return;
948
939
  }
949
940
  // Directory index resolution: /foo/ => /foo/index.html
950
941
  if (path.endsWith('/')) {
951
- var indexPath = join(this.staticDir, path, 'index.html');
942
+ var indexPath = join(resolvedPath, 'index.html');
952
943
  if (existsSync(indexPath) && statSync(indexPath).isFile()) {
953
944
  var indexContent = readFileSync(indexPath);
954
945
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -956,16 +947,15 @@ class BwServeApp {
956
947
  return;
957
948
  }
958
949
  // Directory listing when no index.html
959
- var dirPath = join(this.staticDir, path);
960
- if (this.dirList && existsSync(dirPath) && statSync(dirPath).isDirectory()) {
961
- var listing = this._generateDirListing(path, dirPath);
950
+ if (this.dirList && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
951
+ var listing = this._generateDirListing(path, resolvedPath);
962
952
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
963
953
  res.end(listing);
964
954
  return;
965
955
  }
966
956
  }
967
957
  // Bare directory without trailing slash: /foo => 301 to /foo/
968
- if (!path.endsWith('/') && existsSync(filePath) && statSync(filePath).isDirectory()) {
958
+ if (!path.endsWith('/') && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
969
959
  var qs = url.split('?')[1];
970
960
  var location = path + '/' + (qs ? '?' + qs : '');
971
961
  res.writeHead(301, { 'Location': location });
@@ -982,8 +972,7 @@ class BwServeApp {
982
972
  clientId: clientId2,
983
973
  title: this.title,
984
974
  theme: this.theme,
985
- injectBitwrench: this.injectBitwrench,
986
- allowExec: this.allowExec
975
+ injectBitwrench: this.injectBitwrench
987
976
  });
988
977
  // Store the page path for this client so SSE knows which handler to call
989
978
  this._clients.set(clientId2, { pagePath: path, client: null });
@@ -1042,9 +1031,13 @@ class BwServeApp {
1042
1031
  var pagePath = pending ? pending.pagePath : '/';
1043
1032
  self._clients.set(clientId, { pagePath: pagePath, client: client });
1044
1033
 
1034
+ // Send the handshake as the very first SSE event
1035
+ client._send({ type: 'hello' });
1036
+
1045
1037
  // Keep-alive: send SSE comment periodically
1046
1038
  var keepAlive = setInterval(function() {
1047
1039
  if (!client._closed) {
1040
+ /* c8 ignore next -- keepalive write failure only on socket close */
1048
1041
  try { res.write(':keepalive\n\n'); } catch (e) { /* ignore */ }
1049
1042
  }
1050
1043
  }, self.keepAliveInterval);
@@ -1056,11 +1049,12 @@ class BwServeApp {
1056
1049
  self._clients.delete(clientId);
1057
1050
  });
1058
1051
 
1059
- // Call the page handler
1052
+ // Call the page handler (runs on every connection, including reconnects)
1060
1053
  var handler = self._pages.get(pagePath);
1061
1054
  if (handler) {
1062
1055
  try {
1063
1056
  handler(client);
1057
+ /* c8 ignore next 3 -- page handler error catch; requires throwing handler in SSE context */
1064
1058
  } catch (e) {
1065
1059
  console.error('[bwserve] Page handler error:', e);
1066
1060
  }
@@ -1073,8 +1067,8 @@ class BwServeApp {
1073
1067
  *
1074
1068
  * Routes:
1075
1069
  * action — fire-and-forget action dispatch (no requestId)
1076
- * queryresolve pending query promise
1077
- * mountresolve pending mount promise
1070
+ * eventevent dispatch from client listeners
1071
+ * topictopic dispatch from client pub/sub
1078
1072
  * screenshot — resolve pending screenshot promise
1079
1073
  *
1080
1074
  * @private
@@ -1092,18 +1086,26 @@ class BwServeApp {
1092
1086
  req.on('end', function() {
1093
1087
  try {
1094
1088
  var data = JSON.parse(body);
1095
- 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') {
1096
1095
  // Action/event dispatch (no requestId/pending pattern)
1097
1096
  var action = route === 'event'
1098
1097
  ? '_bw_event'
1099
1098
  : (data.result ? data.result.action : data.action);
1099
+ /* c8 ignore next 3 -- data.data fallback; covered in isolation */
1100
1100
  var payload = route === 'event'
1101
1101
  ? (data.result || data)
1102
1102
  : (data.result ? data.result.data : data.data || data);
1103
1103
  record.client._dispatch(action, payload);
1104
1104
  } else {
1105
- // All other routes: resolve pending promise
1106
- 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
+ }
1107
1109
  }
1108
1110
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
1109
1111
  res.end(JSON.stringify({ ok: true }));
@@ -1121,13 +1123,18 @@ class BwServeApp {
1121
1123
  */
1122
1124
  _serveAttachScript(req, res) {
1123
1125
  try {
1124
- 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 });
1125
1131
  res.writeHead(200, {
1126
1132
  'Content-Type': 'application/javascript; charset=utf-8',
1127
1133
  'Access-Control-Allow-Origin': '*',
1128
1134
  'Cache-Control': 'no-cache'
1129
1135
  });
1130
1136
  res.end(js);
1137
+ /* c8 ignore next 4 -- generateAttachScript is a pure template; cannot throw */
1131
1138
  } catch (err) {
1132
1139
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1133
1140
  res.end('Error generating attach script: ' + err.message);