@matware/e2e-runner 1.0.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reporter.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
7
  import { colors as C } from './logger.js';
8
+ import { ensureProject, saveRun as saveRunToDb } from './db.js';
8
9
 
9
10
  function escapeXml(str) {
10
11
  return String(str)
@@ -14,6 +15,10 @@ function escapeXml(str) {
14
15
  .replace(/"/g, '"');
15
16
  }
16
17
 
18
+ function escapeCdata(str) {
19
+ return String(str).replace(/\]\]>/g, ']]]]><![CDATA[>');
20
+ }
21
+
17
22
  /** Generates a report object from test results */
18
23
  export function generateReport(results) {
19
24
  const passed = results.filter(r => r.success).length;
@@ -52,12 +57,12 @@ export function generateJUnitXML(report) {
52
57
 
53
58
  const logs = (result.consoleLogs || []).map(l => `[${l.type}] ${l.text}`).join('\n');
54
59
  if (logs) {
55
- xml += ` <system-out><![CDATA[${logs}]]></system-out>\n`;
60
+ xml += ` <system-out><![CDATA[${escapeCdata(logs)}]]></system-out>\n`;
56
61
  }
57
62
 
58
63
  const netErrors = (result.networkErrors || []).map(e => `[${e.error || 'unknown'}] ${e.url}`).join('\n');
59
64
  if (netErrors) {
60
- xml += ` <system-err><![CDATA[${netErrors}]]></system-err>\n`;
65
+ xml += ` <system-err><![CDATA[${escapeCdata(netErrors)}]]></system-err>\n`;
61
66
  }
62
67
 
63
68
  xml += ' </testcase>\n';
@@ -89,6 +94,72 @@ export function saveReport(report, screenshotsDir, config = {}) {
89
94
  return saved.length === 1 ? saved[0] : saved;
90
95
  }
91
96
 
97
+ /** Saves a run to history */
98
+ export function saveHistory(report, screenshotsDir, maxRuns = 100) {
99
+ const historyDir = path.join(screenshotsDir, 'history');
100
+ if (!fs.existsSync(historyDir)) {
101
+ fs.mkdirSync(historyDir, { recursive: true });
102
+ }
103
+
104
+ const runId = new Date().toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, '');
105
+ const filename = `run-${runId}.json`;
106
+ const entry = { ...report, runId };
107
+ fs.writeFileSync(path.join(historyDir, filename), JSON.stringify(entry, null, 2));
108
+
109
+ // Auto-prune old runs
110
+ const files = fs.readdirSync(historyDir).filter(f => f.startsWith('run-') && f.endsWith('.json')).sort();
111
+ while (files.length > maxRuns) {
112
+ fs.unlinkSync(path.join(historyDir, files.shift()));
113
+ }
114
+
115
+ return runId;
116
+ }
117
+
118
+ /** Loads history summaries (newest first) */
119
+ export function loadHistory(screenshotsDir) {
120
+ const historyDir = path.join(screenshotsDir, 'history');
121
+ if (!fs.existsSync(historyDir)) return [];
122
+
123
+ return fs.readdirSync(historyDir)
124
+ .filter(f => f.startsWith('run-') && f.endsWith('.json'))
125
+ .sort()
126
+ .reverse()
127
+ .map(f => {
128
+ const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), 'utf-8'));
129
+ return { runId: data.runId, summary: data.summary, generatedAt: data.generatedAt };
130
+ });
131
+ }
132
+
133
+ /** Loads a full history run by runId */
134
+ export function loadHistoryRun(screenshotsDir, runId) {
135
+ const historyDir = path.join(screenshotsDir, 'history');
136
+ const files = fs.existsSync(historyDir)
137
+ ? fs.readdirSync(historyDir).filter(f => f.startsWith('run-') && f.endsWith('.json'))
138
+ : [];
139
+
140
+ const match = files.find(f => {
141
+ const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), 'utf-8'));
142
+ return data.runId === runId;
143
+ });
144
+
145
+ if (!match) return null;
146
+ return JSON.parse(fs.readFileSync(path.join(historyDir, match), 'utf-8'));
147
+ }
148
+
149
+ /** Persists a run to both filesystem history and SQLite (never throws). */
150
+ export function persistRun(report, config, suiteName) {
151
+ const runId = saveHistory(report, config.screenshotsDir, config.maxHistoryRuns);
152
+
153
+ try {
154
+ const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
155
+ saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
156
+ } catch (err) {
157
+ process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
158
+ }
159
+
160
+ return runId;
161
+ }
162
+
92
163
  /** Prints a formatted report summary to the console */
93
164
  export function printReport(report, screenshotsDir) {
94
165
  const { summary } = report;
@@ -139,6 +210,18 @@ export function printReport(report, screenshotsDir) {
139
210
  });
140
211
  }
141
212
 
213
+ const networkRequests = report.results.filter(r => r.networkLogs?.length > 0);
214
+ if (networkRequests.length > 0) {
215
+ console.log(`\n${C.cyan}${C.bold}NETWORK REQUESTS:${C.reset}`);
216
+ networkRequests.forEach(r => {
217
+ console.log(` ${C.cyan}▸${C.reset} ${r.name}:`);
218
+ r.networkLogs.forEach(n => {
219
+ const statusColor = n.status < 300 ? C.green : n.status < 400 ? C.yellow : C.red;
220
+ console.log(` ${C.dim}${n.method}${C.reset} ${statusColor}${n.status}${C.reset} ${n.url} ${C.dim}(${n.duration}ms)${C.reset}`);
221
+ });
222
+ });
223
+ }
224
+
142
225
  if (screenshotsDir) {
143
226
  console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
144
227
  console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
package/src/runner.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { connectToPool, waitForPool } from './pool.js';
10
+ import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
11
11
  import { executeAction } from './actions.js';
12
12
  import { log, colors as C } from './logger.js';
13
13
 
@@ -47,8 +47,28 @@ function normalizeTestData(data) {
47
47
  return { tests: data.tests || [], hooks: data.hooks || {} };
48
48
  }
49
49
 
50
+ /** Waits until the pool has capacity before connecting */
51
+ async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
52
+ const start = Date.now();
53
+ while (Date.now() - start < maxWaitMs) {
54
+ try {
55
+ const status = await getPoolStatus(poolUrl);
56
+ if (status.available && status.running < status.maxConcurrent) {
57
+ return;
58
+ }
59
+ log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
60
+ } catch {
61
+ // Pool unreachable, let connectToPool handle the error
62
+ return;
63
+ }
64
+ await sleep(pollIntervalMs);
65
+ }
66
+ // Timeout — proceed anyway and let connectToPool deal with it
67
+ log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
68
+ }
69
+
50
70
  /** Runs a single test end-to-end */
51
- export async function runTest(test, config, hooks = {}) {
71
+ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
52
72
  let browser = null;
53
73
  let page = null;
54
74
 
@@ -60,9 +80,12 @@ export async function runTest(test, config, hooks = {}) {
60
80
  error: null,
61
81
  consoleLogs: [],
62
82
  networkErrors: [],
83
+ networkLogs: [],
63
84
  };
85
+ const pendingBodies = [];
64
86
 
65
87
  try {
88
+ await waitForSlot(config.poolUrl);
66
89
  browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
67
90
  page = await browser.newPage();
68
91
  await page.setViewport(config.viewport);
@@ -74,32 +97,95 @@ export async function runTest(test, config, hooks = {}) {
74
97
  result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
75
98
  });
76
99
 
100
+ const requestTimings = new Map();
101
+ page.on('request', (req) => {
102
+ const rt = req.resourceType();
103
+ if (rt === 'xhr' || rt === 'fetch') requestTimings.set(req, Date.now());
104
+ });
105
+ page.on('response', (resp) => {
106
+ const req = resp.request();
107
+ const startMs = requestTimings.get(req);
108
+ if (startMs !== undefined) {
109
+ requestTimings.delete(req);
110
+ const entry = {
111
+ url: req.url(),
112
+ method: req.method(),
113
+ status: resp.status(),
114
+ statusText: resp.statusText(),
115
+ duration: Date.now() - startMs,
116
+ requestHeaders: req.headers(),
117
+ requestBody: null,
118
+ responseHeaders: resp.headers(),
119
+ responseBody: null,
120
+ };
121
+ try { entry.requestBody = req.postData() || null; } catch { /* */ }
122
+ result.networkLogs.push(entry);
123
+ // Read response body async — collect promise for later flush
124
+ const bodyPromise = resp.text().then(body => {
125
+ entry.responseBody = body && body.length > 51200 ? body.slice(0, 51200) + '\n...[truncated]' : body;
126
+ }).catch(() => { /* response may be unavailable */ });
127
+ pendingBodies.push(bodyPromise);
128
+ }
129
+ });
130
+
77
131
  // Run beforeEach hook
78
132
  if (hooks.beforeEach?.length) {
79
133
  await executeHookActions(page, hooks.beforeEach, config);
80
134
  }
81
135
 
82
- for (const action of test.actions) {
136
+ for (let i = 0; i < test.actions.length; i++) {
137
+ const action = test.actions[i];
83
138
  const actionStart = Date.now();
84
139
  try {
85
- const actionResult = await executeAction(page, action, config);
140
+ let actionResult;
141
+ if (action.type === 'assert_no_network_errors') {
142
+ // Handled inline — needs access to result.networkErrors
143
+ if (result.networkErrors.length > 0) {
144
+ const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
145
+ throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
146
+ }
147
+ actionResult = null;
148
+ } else {
149
+ actionResult = await executeAction(page, action, config);
150
+ }
151
+ const actionDuration = Date.now() - actionStart;
86
152
  result.actions.push({
87
153
  ...action,
88
154
  success: true,
89
- duration: Date.now() - actionStart,
155
+ duration: actionDuration,
90
156
  result: actionResult,
91
157
  });
158
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
92
159
  } catch (error) {
160
+ const actionDuration = Date.now() - actionStart;
93
161
  result.actions.push({
94
162
  ...action,
95
163
  success: false,
96
- duration: Date.now() - actionStart,
164
+ duration: actionDuration,
97
165
  error: error.message,
98
166
  });
167
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
99
168
  throw error;
100
169
  }
101
170
  }
102
171
 
172
+ // Fail the test if failOnNetworkError is enabled and network errors occurred
173
+ if (config.failOnNetworkError && result.networkErrors.length > 0) {
174
+ const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
175
+ throw new Error(`Network errors detected (failOnNetworkError=true): ${result.networkErrors.length} error(s): ${summary}`);
176
+ }
177
+
178
+ // Auto-capture verification screenshot if test has "expect"
179
+ if (test.expect && page) {
180
+ result.expect = test.expect;
181
+ try {
182
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
183
+ const verifyPath = path.join(config.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
184
+ await page.screenshot({ path: verifyPath, fullPage: true });
185
+ result.verificationScreenshot = verifyPath;
186
+ } catch { /* page may be dead */ }
187
+ }
188
+
103
189
  // Run afterEach hook (success path)
104
190
  if (hooks.afterEach?.length) {
105
191
  await executeHookActions(page, hooks.afterEach, config);
@@ -115,12 +201,17 @@ export async function runTest(test, config, hooks = {}) {
115
201
 
116
202
  if (page) {
117
203
  try {
118
- const errorScreenshot = path.join(config.screenshotsDir, `error-${test.name}-${Date.now()}.png`);
204
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
205
+ const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
119
206
  await page.screenshot({ path: errorScreenshot, fullPage: true });
120
207
  result.errorScreenshot = errorScreenshot;
121
208
  } catch { /* page may be dead */ }
122
209
  }
123
210
  } finally {
211
+ // Flush pending response body reads before disconnecting
212
+ if (pendingBodies.length > 0) {
213
+ try { await Promise.allSettled(pendingBodies); } catch { /* */ }
214
+ }
124
215
  result.endTime = new Date().toISOString();
125
216
  if (page) {
126
217
  try { result.finalUrl = page.url(); } catch { /* */ }
@@ -155,6 +246,14 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
155
246
  }
156
247
  }
157
248
 
249
+ const concurrency = config.concurrency || 3;
250
+ const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
251
+ const _proj = config.projectName || null;
252
+ const _cwd = config._cwd || null;
253
+ const _triggeredBy = config.triggeredBy || 'unknown';
254
+ const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
255
+ _progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
256
+
158
257
  const results = [];
159
258
  const queue = [...tests];
160
259
  let activeCount = 0;
@@ -164,6 +263,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
164
263
  const test = queue.shift();
165
264
  activeCount++;
166
265
  log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
266
+ _progress({ event: 'test:start', name: test.name, activeCount, queueRemaining: queue.length });
167
267
 
168
268
  const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
169
269
  const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
@@ -176,7 +276,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
176
276
  });
177
277
 
178
278
  try {
179
- result = await Promise.race([runTest(test, config, hooks), timeoutPromise]);
279
+ result = await Promise.race([runTest(test, config, hooks, _progress), timeoutPromise]);
180
280
  } catch (error) {
181
281
  result = {
182
282
  name: test.name,
@@ -187,6 +287,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
187
287
  error: error.message,
188
288
  consoleLogs: [],
189
289
  networkErrors: [],
290
+ networkLogs: [],
190
291
  };
191
292
  }
192
293
 
@@ -195,12 +296,16 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
195
296
 
196
297
  if (result.success || attempt === maxAttempts) break;
197
298
  log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
299
+ _progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
198
300
  await sleep(config.retryDelay || 1000);
199
301
  }
200
302
 
201
303
  results.push(result);
202
304
  activeCount--;
203
305
 
306
+ const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
307
+ _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots });
308
+
204
309
  if (result.success) {
205
310
  const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
206
311
  log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${flaky}`);
@@ -219,7 +324,6 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
219
324
  }
220
325
  };
221
326
 
222
- const concurrency = config.concurrency || 3;
223
327
  const workers = [];
224
328
  for (let i = 0; i < Math.min(concurrency, tests.length); i++) {
225
329
  workers.push(worker());
@@ -243,6 +347,12 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
243
347
  }
244
348
  }
245
349
 
350
+ {
351
+ const passed = results.filter(r => r.success).length;
352
+ const failed = results.filter(r => !r.success).length;
353
+ _progress({ event: 'run:complete', summary: { total: results.length, passed, failed } });
354
+ }
355
+
246
356
  return results;
247
357
  }
248
358
 
package/src/verify.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Bug Verification Orchestrator
3
+ *
4
+ * Combines issue fetch + AI test generation + test execution into a single pipeline.
5
+ * Tests assert CORRECT behavior: failures = bug confirmed, all pass = not reproducible.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { fetchIssue } from './issues.js';
11
+ import { generateTests } from './ai-generate.js';
12
+ import { waitForPool } from './pool.js';
13
+ import { runTestsParallel } from './runner.js';
14
+ import { generateReport, saveReport, persistRun } from './reporter.js';
15
+
16
+ /**
17
+ * Fetches an issue, generates tests via Claude API, runs them, and reports whether the bug is confirmed.
18
+ *
19
+ * @param {string} url - GitHub or GitLab issue URL
20
+ * @param {object} config - Loaded config (must include anthropicApiKey or ANTHROPIC_API_KEY env)
21
+ * @returns {Promise<{ issue: object, report: object, bugConfirmed: boolean, tests: object[], suiteName: string }>}
22
+ */
23
+ export async function verifyIssue(url, config) {
24
+ // 1. Fetch issue
25
+ const issue = fetchIssue(url);
26
+
27
+ // 2. Generate tests via Claude API
28
+ const { tests, suiteName } = await generateTests(issue, config);
29
+
30
+ // 3. Save tests to a temp file (underscore prefix for cleanup identification)
31
+ const testFile = path.join(config.testsDir, `_verify-${suiteName}.json`);
32
+ if (!fs.existsSync(config.testsDir)) {
33
+ fs.mkdirSync(config.testsDir, { recursive: true });
34
+ }
35
+ fs.writeFileSync(testFile, JSON.stringify(tests, null, 2));
36
+
37
+ try {
38
+ // 4. Build hooks (inject auth if provided)
39
+ const hooks = {};
40
+ if (config.authToken) {
41
+ const storageKey = config.authStorageKey || 'accessToken';
42
+ hooks.beforeEach = [
43
+ { type: 'goto', value: '/' },
44
+ { type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${config.authToken}')` },
45
+ { type: 'goto', value: '/' },
46
+ { type: 'wait', value: '1000' },
47
+ ];
48
+ }
49
+
50
+ // 5. Wait for pool and run
51
+ await waitForPool(config.poolUrl);
52
+ const results = await runTestsParallel(tests, config, hooks);
53
+ const report = generateReport(results);
54
+ saveReport(report, config.screenshotsDir, config);
55
+ persistRun(report, config, suiteName);
56
+
57
+ // 6. Interpret results
58
+ const bugConfirmed = report.summary.failed > 0;
59
+
60
+ return { issue, report, bugConfirmed, tests, suiteName };
61
+ } finally {
62
+ // 7. Clean up temp file
63
+ try { fs.unlinkSync(testFile); } catch { /* already gone */ }
64
+ }
65
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Minimal WebSocket server — RFC 6455 text frames only, no external deps.
3
+ *
4
+ * Usage:
5
+ * const wss = createWebSocketServer(httpServer);
6
+ * wss.broadcast(JSON.stringify({ event: 'hello' }));
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+
11
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
12
+ const OP_TEXT = 0x01;
13
+ const OP_CLOSE = 0x08;
14
+ const OP_PING = 0x09;
15
+ const OP_PONG = 0x0a;
16
+
17
+ function acceptKey(key) {
18
+ return crypto.createHash('sha1').update(key + GUID).digest('base64');
19
+ }
20
+
21
+ function encodeFrame(data, opcode = OP_TEXT) {
22
+ const buf = Buffer.from(data, 'utf-8');
23
+ const len = buf.length;
24
+ let header;
25
+
26
+ if (len < 126) {
27
+ header = Buffer.alloc(2);
28
+ header[0] = 0x80 | opcode;
29
+ header[1] = len;
30
+ } else if (len < 65536) {
31
+ header = Buffer.alloc(4);
32
+ header[0] = 0x80 | opcode;
33
+ header[1] = 126;
34
+ header.writeUInt16BE(len, 2);
35
+ } else {
36
+ header = Buffer.alloc(10);
37
+ header[0] = 0x80 | opcode;
38
+ header[1] = 127;
39
+ header.writeBigUInt64BE(BigInt(len), 2);
40
+ }
41
+
42
+ return Buffer.concat([header, buf]);
43
+ }
44
+
45
+ function decodeFrame(buffer) {
46
+ if (buffer.length < 2) return null;
47
+
48
+ const firstByte = buffer[0];
49
+ const opcode = firstByte & 0x0f;
50
+ const secondByte = buffer[1];
51
+ const masked = (secondByte & 0x80) !== 0;
52
+ let payloadLen = secondByte & 0x7f;
53
+ let offset = 2;
54
+
55
+ if (payloadLen === 126) {
56
+ if (buffer.length < 4) return null;
57
+ payloadLen = buffer.readUInt16BE(2);
58
+ offset = 4;
59
+ } else if (payloadLen === 127) {
60
+ if (buffer.length < 10) return null;
61
+ payloadLen = Number(buffer.readBigUInt64BE(2));
62
+ offset = 10;
63
+ }
64
+
65
+ if (masked) {
66
+ if (buffer.length < offset + 4 + payloadLen) return null;
67
+ const mask = buffer.slice(offset, offset + 4);
68
+ offset += 4;
69
+ const payload = Buffer.alloc(payloadLen);
70
+ for (let i = 0; i < payloadLen; i++) {
71
+ payload[i] = buffer[offset + i] ^ mask[i % 4];
72
+ }
73
+ return { opcode, payload, totalLength: offset + payloadLen };
74
+ }
75
+
76
+ if (buffer.length < offset + payloadLen) return null;
77
+ return { opcode, payload: buffer.slice(offset, offset + payloadLen), totalLength: offset + payloadLen };
78
+ }
79
+
80
+ export function createWebSocketServer(httpServer, options = {}) {
81
+ const clients = new Set();
82
+
83
+ httpServer.on('upgrade', (req, socket, head) => {
84
+ // Validate Origin to prevent cross-site WebSocket hijacking
85
+ const origin = req.headers.origin;
86
+ if (origin && options.allowedOrigins) {
87
+ if (!options.allowedOrigins.includes(origin)) {
88
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
89
+ socket.destroy();
90
+ return;
91
+ }
92
+ }
93
+
94
+ const key = req.headers['sec-websocket-key'];
95
+ if (!key) {
96
+ socket.destroy();
97
+ return;
98
+ }
99
+
100
+ const accept = acceptKey(key);
101
+ socket.write(
102
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
103
+ 'Upgrade: websocket\r\n' +
104
+ 'Connection: Upgrade\r\n' +
105
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
106
+ '\r\n'
107
+ );
108
+
109
+ clients.add(socket);
110
+ let recvBuffer = Buffer.alloc(0);
111
+ const MAX_BUFFER = 1024 * 1024; // 1MB max receive buffer
112
+
113
+ socket.on('data', (data) => {
114
+ recvBuffer = Buffer.concat([recvBuffer, data]);
115
+ if (recvBuffer.length > MAX_BUFFER) {
116
+ clients.delete(socket);
117
+ try { socket.write(encodeFrame('', OP_CLOSE)); } catch { /* */ }
118
+ socket.destroy();
119
+ return;
120
+ }
121
+
122
+ while (recvBuffer.length > 0) {
123
+ const frame = decodeFrame(recvBuffer);
124
+ if (!frame) break;
125
+
126
+ recvBuffer = recvBuffer.slice(frame.totalLength);
127
+
128
+ switch (frame.opcode) {
129
+ case OP_TEXT:
130
+ // We don't process client messages for now
131
+ break;
132
+ case OP_PING:
133
+ try { socket.write(encodeFrame(frame.payload.toString(), OP_PONG)); } catch { /* */ }
134
+ break;
135
+ case OP_CLOSE:
136
+ try { socket.write(encodeFrame('', OP_CLOSE)); } catch { /* */ }
137
+ socket.end();
138
+ break;
139
+ }
140
+ }
141
+ });
142
+
143
+ socket.on('close', () => clients.delete(socket));
144
+ socket.on('error', () => clients.delete(socket));
145
+
146
+ // Send connected event
147
+ try {
148
+ socket.write(encodeFrame(JSON.stringify({ event: 'connected', timestamp: new Date().toISOString() })));
149
+ } catch { /* */ }
150
+
151
+ // Notify listener of new connection (for replaying state)
152
+ if (options.onConnect) {
153
+ try { options.onConnect(socket); } catch { /* */ }
154
+ }
155
+ });
156
+
157
+ return {
158
+ broadcast(data) {
159
+ const frame = encodeFrame(data);
160
+ for (const client of clients) {
161
+ try { client.write(frame); } catch { clients.delete(client); }
162
+ }
163
+ },
164
+ sendTo(socket, data) {
165
+ try { socket.write(encodeFrame(data)); } catch { /* */ }
166
+ },
167
+ get clientCount() {
168
+ return clients.size;
169
+ },
170
+ close() {
171
+ for (const client of clients) {
172
+ try { client.write(encodeFrame('', OP_CLOSE)); client.end(); } catch { /* */ }
173
+ }
174
+ clients.clear();
175
+ },
176
+ };
177
+ }