@dpkrn/nodetunnel 1.0.9 → 1.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.
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import net from 'node:net';
3
3
  import { Session } from 'yamux-js/lib/session.js';
4
- import { addLog, newLogId, setMaxRequestLogs } from './logstore.js';
5
- import { startInspector } from './inspector.js';
4
+ import { addLog, newLogId, setMaxRequestLogs } from '../inspector/logstore.js';
5
+ import { startInspector } from '../inspector/inspector.js';
6
6
  // import { version } from '../../../package.json' with { type: 'json' };
7
7
 
8
8
  const defaultMuxConfig = {
@@ -140,16 +140,19 @@ async function handleStream(stream, port) {
140
140
  stream.end(Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8'));
141
141
 
142
142
  addLog({
143
- id: newLogId(),
144
- method,
145
- path,
146
- headers: raw,
147
- body: body.toString('utf8'),
148
- status: resp.status,
149
- resp_body: respBody.toString('utf8'),
150
- resp_headers: headersToObject(resp.headers),
151
- timestamp: new Date().toISOString(),
152
- duration_ms: Date.now() - started,
143
+ id: `req_${newLogId()}`,
144
+ request: {
145
+ method,
146
+ path,
147
+ headers: raw,
148
+ body: body.length ? body.toString('base64') : '',
149
+ },
150
+ response: {
151
+ statusCode: resp.status,
152
+ headers: headersToObject(resp.headers),
153
+ body: respBody.length ? respBody.toString('base64') : '',
154
+ },
155
+ durationMs: Date.now() - started,
153
156
  });
154
157
  } catch (e) {
155
158
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpkrn/nodetunnel",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "Expose a local HTTP server through a devtunnel/gotunnel-compatible server (yamux + JSON). Node.js 18+.",
5
5
  "keywords": [
6
6
  "tunnel",
@@ -39,6 +39,7 @@
39
39
  "internal",
40
40
  "cmd",
41
41
  "README.md",
42
+ "CHANGELOG.md",
42
43
  "LICENSE"
43
44
  ],
44
45
  "engines": {
@@ -49,6 +50,7 @@
49
50
  "prepublishOnly": "node -e \"import('./pkg/tunnel/tunnel.js').then(() => console.log('pack ok')).catch(e => { console.error(e); process.exit(1); })\""
50
51
  },
51
52
  "dependencies": {
53
+ "undici": "^7.25.0",
52
54
  "ws": "^8.18.0",
53
55
  "yamux-js": "^0.2.0"
54
56
  },
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { newTunnel } from '../../internal/tunnel/tunnel.js';
21
- import { inspectorHTTPBaseURL } from '../../internal/tunnel/inspector.js';
21
+ import { inspectorHTTPBaseURL } from '../../internal/inspector/inspector.js';
22
22
 
23
23
  /**
24
24
  * Print a formatted success message for the tunnel.
@@ -1,482 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8"/>
5
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
- <title>Tunnel traffic — dev</title>
7
- <style>
8
- /* Theme tokens: dark (default), terminal (green CRT), light */
9
- body.theme-dark {
10
- --bg: #0d1117;
11
- --panel: #161b22;
12
- --border: #30363d;
13
- --text: #e6edf3;
14
- --muted: #8b949e;
15
- --accent: #58a6ff;
16
- --green: #3fb950;
17
- --danger: #f85149;
18
- --row-alt: #21262d;
19
- --log-card: #1c2128;
20
- --kv-td: #1c2128;
21
- --kv-input-bg: #0d1117;
22
- --btn-hover: #262c36;
23
- --btn-primary: #238636;
24
- --btn-primary-border: #2ea043;
25
- --btn-primary-hover: #2ea043;
26
- --selected-ring: #388bfd;
27
- --err-bg: rgba(248, 81, 73, 0.13);
28
- --font-ui: ui-sans-serif, system-ui, -apple-system, sans-serif;
29
- --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
30
- }
31
- body.theme-terminal {
32
- --bg: #070807;
33
- --panel: #0a0f0a;
34
- --border: #1e4a28;
35
- --text: #c8f0c8;
36
- --muted: #5a8a5a;
37
- --accent: #00ff88;
38
- --green: #39ff14;
39
- --danger: #ff6b6b;
40
- --row-alt: #0f1810;
41
- --log-card: #0c120d;
42
- --kv-td: #080d09;
43
- --kv-input-bg: #050805;
44
- --btn-hover: #142818;
45
- --btn-primary: #1a6b2e;
46
- --btn-primary-border: #39ff14;
47
- --btn-primary-hover: #228b3a;
48
- --selected-ring: #39ff14;
49
- --err-bg: rgba(255, 107, 107, 0.12);
50
- --font-ui: "JetBrains Mono", "SF Mono", "Cascadia Mono", "Cascadia Code", Consolas, monospace;
51
- --font-mono: "JetBrains Mono", "SF Mono", "Cascadia Mono", Menlo, monospace;
52
- }
53
- body.theme-light {
54
- --bg: #f6f8fa;
55
- --panel: #ffffff;
56
- --border: #d0d7de;
57
- --text: #1f2328;
58
- --muted: #656d76;
59
- --accent: #0969da;
60
- --green: #1a7f37;
61
- --danger: #cf222e;
62
- --row-alt: #f3f4f6;
63
- --log-card: #ffffff;
64
- --kv-td: #f6f8fa;
65
- --kv-input-bg: #ffffff;
66
- --btn-hover: #eaeef2;
67
- --btn-primary: #2da44e;
68
- --btn-primary-border: #2da44e;
69
- --btn-primary-hover: #2c974b;
70
- --selected-ring: #0969da;
71
- --err-bg: rgba(207, 34, 46, 0.08);
72
- --font-ui: ui-sans-serif, system-ui, -apple-system, sans-serif;
73
- --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
74
- }
75
- * { box-sizing: border-box; }
76
- body {
77
- font-family: var(--font-ui);
78
- margin: 0; padding: 1rem 1.25rem 2rem;
79
- background: var(--bg); color: var(--text); min-height: 100vh;
80
- }
81
- body.theme-terminal {
82
- text-shadow: 0 0 1px rgba(57, 255, 20, 0.15);
83
- }
84
- header { max-width: 1200px; margin: 0 auto 1rem; }
85
- header h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.35rem 0; }
86
- header p { margin: 0; color: var(--muted); font-size: 0.875rem; }
87
- .shell {
88
- display: grid; grid-template-columns: minmax(300px, 38%) minmax(0, 1fr);
89
- gap: 1rem; align-items: start; max-width: 1200px; margin: 0 auto;
90
- }
91
- @media (max-width: 880px) { .shell { grid-template-columns: 1fr; } }
92
- .col-left {
93
- background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
94
- padding: 0.65rem 0.75rem; min-height: 200px;
95
- }
96
- .col-left .left-topbar {
97
- display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: space-between; align-items: center;
98
- margin-bottom: 0.65rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border);
99
- }
100
- .col-right {
101
- background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
102
- padding: 0.85rem 1rem 1rem; min-height: 280px;
103
- }
104
- .detail-toolbar {
105
- display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start;
106
- gap: 0.5rem; margin-bottom: 0.75rem;
107
- }
108
- .detail-toolbar .toolbar-actions { display: flex; gap: 0.45rem; flex-shrink: 0; }
109
- #detail-badge { font-size: 12px; color: var(--muted); max-width: 55%; line-height: 1.35; }
110
- .btn {
111
- padding: 0.4rem 0.85rem; font-size: 13px; cursor: pointer; border-radius: 6px;
112
- border: 1px solid var(--border); background: var(--row-alt); color: var(--text); font-weight: 500;
113
- }
114
- .btn:hover:not(:disabled) { background: var(--btn-hover); }
115
- .btn-primary { background: var(--btn-primary); border-color: var(--btn-primary-border); color: #fff; }
116
- .btn-primary:hover:not(:disabled) { background: var(--btn-primary-hover); }
117
- .btn-sm { padding: 0.25rem 0.55rem; font-size: 12px; }
118
- #log-list { display: flex; flex-direction: column; gap: 0.5rem; }
119
- .log-card {
120
- background: var(--log-card); border: 1px solid var(--border); border-radius: 8px;
121
- overflow: hidden;
122
- }
123
- .log-card.selected { box-shadow: 0 0 0 2px var(--accent); border-color: var(--selected-ring); }
124
- .log-card-head {
125
- display: grid;
126
- grid-template-columns: auto 1fr auto auto auto;
127
- gap: 0.45rem 0.6rem; align-items: center;
128
- padding: 0.5rem 0.6rem; font-size: 13px;
129
- }
130
- .log-card-head .method { font-weight: 600; color: var(--accent); }
131
- .log-card-head .path { font-family: var(--font-mono); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
132
- .log-card-head .status { color: var(--green); font-weight: 600; font-size: 12px; }
133
- .log-card-head .ms { color: var(--muted); font-size: 11px; }
134
- .btn-toggle {
135
- border: 1px solid var(--border); background: var(--row-alt); color: var(--text);
136
- border-radius: 6px; padding: 0.25rem 0.5rem; font-size: 11px; cursor: pointer;
137
- }
138
- .btn-toggle:hover { border-color: var(--accent); color: var(--accent); }
139
- .section-title {
140
- font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
141
- color: var(--muted); margin: 0.85rem 0 0.45rem 0; font-weight: 600;
142
- }
143
- .section-title:first-of-type { margin-top: 0; }
144
- #req-slot { min-height: 1rem; }
145
- table.kv { width: 100%; border-collapse: collapse; font-size: 12px; margin: 0; }
146
- table.kv th {
147
- text-align: left; vertical-align: top; width: 6.5rem;
148
- padding: 0.4rem 0.55rem; border: 1px solid var(--border);
149
- background: var(--row-alt); color: var(--muted); font-weight: 600;
150
- }
151
- table.kv td {
152
- padding: 0.4rem 0.55rem; border: 1px solid var(--border);
153
- word-break: break-word; background: var(--kv-td);
154
- }
155
- table.kv td pre, table.kv .mono {
156
- margin: 0; font-family: var(--font-mono); font-size: 11px;
157
- white-space: pre-wrap; max-height: 180px; overflow: auto;
158
- }
159
- table.kv-edit input[type="text"], table.kv-edit textarea {
160
- width: 100%; margin: 0; padding: 0.35rem 0.45rem; font-size: 12px;
161
- font-family: var(--font-mono);
162
- border: 1px solid var(--border); border-radius: 4px; background: var(--kv-input-bg); color: var(--text);
163
- }
164
- table.kv-edit textarea { resize: vertical; min-height: 3.5rem; display: block; }
165
- table.kv-edit input:focus, table.kv-edit textarea:focus {
166
- outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent);
167
- }
168
- .err-box {
169
- color: var(--danger); font-family: var(--font-mono); font-size: 12px;
170
- padding: 0.5rem; border: 1px solid var(--danger); border-radius: 6px; background: var(--err-bg);
171
- }
172
- </style>
173
- </head>
174
- <body class="__THEME_CLASS__">
175
- <header>
176
- <h1>Tunnel traffic</h1>
177
- <p>Left: request list. Right: request/response for the <strong>latest</strong> capture until you click <strong>Show</strong> on a row. Replay updates the Response panel.</p>
178
- </header>
179
- <div class="shell">
180
- <aside class="col-left">
181
- <div class="left-topbar">
182
- <button type="button" class="btn btn-sm" id="btn-latest" title="Show most recent in the right panel">Latest</button>
183
- <button type="button" class="btn btn-sm" id="clear-all">Clear all</button>
184
- </div>
185
- <div id="log-list"></div>
186
- </aside>
187
- <section class="col-right">
188
- <div class="detail-toolbar">
189
- <span id="detail-badge">No captures yet.</span>
190
- <div class="toolbar-actions">
191
- <button type="button" class="btn btn-sm btn-primary" id="btn-mod-replay" disabled>Modify</button>
192
- <button type="button" class="btn btn-sm" id="btn-reset-req" disabled>Reset</button>
193
- </div>
194
- </div>
195
- <div class="section-title">Request</div>
196
- <div id="req-slot"><p style="color:var(--muted);font-size:13px;margin:0">Waiting for traffic…</p></div>
197
- <div class="section-title"><span id="resp-label">Response</span></div>
198
- <div id="resp-slot"></div>
199
- </section>
200
- </div>
201
- <script>
202
- (function () {
203
- var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
204
- var ws = new WebSocket(proto + '//' + location.host + '/ws');
205
- var logList = document.getElementById('log-list');
206
- var clearAllBtn = document.getElementById('clear-all');
207
- var btnLatest = document.getElementById('btn-latest');
208
- var detailBadge = document.getElementById('detail-badge');
209
- var reqSlot = document.getElementById('req-slot');
210
- var respSlot = document.getElementById('resp-slot');
211
- var respLabel = document.getElementById('resp-label');
212
- var btnModReplay = document.getElementById('btn-mod-replay');
213
- var btnResetReq = document.getElementById('btn-reset-req');
214
- var originals = {};
215
- var latestId = null;
216
- var focusedId = null;
217
- var editing = false;
218
- var respIsReplay = false;
219
-
220
- function effectiveId() {
221
- return focusedId || latestId;
222
- }
223
- function updateCardSelection() {
224
- var nodes = logList.querySelectorAll('.log-card');
225
- var i;
226
- for (i = 0; i < nodes.length; i++) {
227
- nodes[i].classList.toggle('selected', focusedId && nodes[i].getAttribute('data-id') === focusedId);
228
- }
229
- }
230
- function updateBadge() {
231
- var id = effectiveId();
232
- if (!id || !originals[id]) {
233
- detailBadge.textContent = 'No captures yet.';
234
- return;
235
- }
236
- var log = originals[id];
237
- var verb = focusedId ? 'Selected' : 'Latest';
238
- detailBadge.textContent = verb + ' · ' + log.method + ' ' + log.path + ' · ' + String(log.status);
239
- }
240
-
241
- function escapeHtml(s) {
242
- if (s == null) return '';
243
- var d = document.createElement('div');
244
- d.textContent = s;
245
- return d.innerHTML;
246
- }
247
- function escapeAttr(s) {
248
- return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
249
- }
250
- function headerRows(h) {
251
- if (!h || typeof h !== 'object') return '';
252
- var keys = Object.keys(h).sort();
253
- var i, k, parts = [];
254
- for (i = 0; i < keys.length; i++) {
255
- k = keys[i];
256
- parts.push('<tr><th>' + escapeHtml(k) + '</th><td class="mono">' + escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : String(h[k])) + '</td></tr>');
257
- }
258
- return parts.join('');
259
- }
260
- function renderReqView(log) {
261
- return '<table class="kv"><tbody>' +
262
- '<tr><th>Method</th><td class="mono">' + escapeHtml(log.method) + '</td></tr>' +
263
- '<tr><th>Path</th><td class="mono">' + escapeHtml(log.path) + '</td></tr>' +
264
- headerRows(log.headers) +
265
- '<tr><th>Body</th><td><pre>' + escapeHtml(log.body != null ? String(log.body) : '') + '</pre></td></tr>' +
266
- '</tbody></table>';
267
- }
268
- function renderReqEdit(log) {
269
- var h = log.headers || {};
270
- return '<table class="kv kv-edit"><tbody>' +
271
- '<tr><th>Method</th><td><input type="text" class="f-method" value="' + escapeAttr(log.method) + '"/></td></tr>' +
272
- '<tr><th>Path</th><td><input type="text" class="f-path" value="' + escapeAttr(log.path) + '"/></td></tr>' +
273
- '<tr><th>Headers</th><td><textarea class="f-headers" rows="5">' + escapeHtml(JSON.stringify(h, null, 2)) + '</textarea></td></tr>' +
274
- '<tr><th>Body</th><td><textarea class="f-body" rows="6">' + escapeHtml(log.body != null ? String(log.body) : '') + '</textarea></td></tr>' +
275
- '</tbody></table>';
276
- }
277
- function renderRespTable(log) {
278
- return '<table class="kv"><tbody>' +
279
- '<tr><th>Status</th><td class="mono">' + escapeHtml(String(log.status)) + '</td></tr>' +
280
- headerRows(log.resp_headers) +
281
- '<tr><th>Body</th><td><pre>' + escapeHtml(log.resp_body != null ? String(log.resp_body) : '') + '</pre></td></tr>' +
282
- '</tbody></table>';
283
- }
284
- function renderReplayKv(data) {
285
- if (data.error) {
286
- return '<div class="err-box">' + escapeHtml(String(data.error)) + '</div>';
287
- }
288
- return '<table class="kv"><tbody>' +
289
- '<tr><th>Status</th><td class="mono">' + escapeHtml(String(data.status)) + '</td></tr>' +
290
- headerRows(data.headers) +
291
- '<tr><th>Body</th><td><pre>' + escapeHtml(data.body != null ? String(data.body) : '') + '</pre></td></tr>' +
292
- '</tbody></table>';
293
- }
294
- function snapshotLog(log) {
295
- try {
296
- return JSON.parse(JSON.stringify(log));
297
- } catch (e) {
298
- return log;
299
- }
300
- }
301
- function syncModReplayBtn() {
302
- var ed = !!reqSlot.querySelector('.kv-edit');
303
- btnModReplay.textContent = ed ? 'Replay' : 'Modify';
304
- }
305
- function getPayloadFromPanel() {
306
- var id = effectiveId();
307
- if (!id || !originals[id]) throw new Error('no selection');
308
- var edit = reqSlot.querySelector('.kv-edit');
309
- if (edit) {
310
- var method = reqSlot.querySelector('.f-method').value;
311
- var path = reqSlot.querySelector('.f-path').value;
312
- var body = reqSlot.querySelector('.f-body').value;
313
- var headersRaw = reqSlot.querySelector('.f-headers').value.trim();
314
- var headers = {};
315
- if (headersRaw) headers = JSON.parse(headersRaw);
316
- return { method: method, path: path, headers: headers, body: body };
317
- }
318
- var o = originals[id];
319
- return {
320
- method: o.method,
321
- path: o.path,
322
- headers: o.headers || {},
323
- body: o.body != null ? String(o.body) : ''
324
- };
325
- }
326
- function paint() {
327
- var id = effectiveId();
328
- updateBadge();
329
- updateCardSelection();
330
- if (!id || !originals[id]) {
331
- reqSlot.innerHTML = '<p style="color:var(--muted);font-size:13px;margin:0">Waiting for traffic…</p>';
332
- respSlot.innerHTML = '';
333
- respLabel.textContent = 'Response';
334
- btnModReplay.disabled = true;
335
- btnResetReq.disabled = true;
336
- return;
337
- }
338
- btnModReplay.disabled = false;
339
- btnResetReq.disabled = false;
340
- var log = originals[id];
341
- if (!editing) {
342
- reqSlot.innerHTML = renderReqView(log);
343
- } else {
344
- reqSlot.innerHTML = renderReqEdit(log);
345
- }
346
- if (!respIsReplay) {
347
- respSlot.innerHTML = renderRespTable(log);
348
- respLabel.textContent = 'Response';
349
- }
350
- syncModReplayBtn();
351
- }
352
- function wireCardHead(card, id) {
353
- card.querySelector('.btn-toggle').addEventListener('click', function (e) {
354
- e.stopPropagation();
355
- focusedId = id;
356
- editing = false;
357
- respIsReplay = false;
358
- paint();
359
- });
360
- }
361
- function appendLogCard(log) {
362
- var id = log.id || ('tmp-' + Date.now() + '-' + Math.random());
363
- log.id = id;
364
- originals[id] = snapshotLog(log);
365
- latestId = id;
366
- var card = document.createElement('article');
367
- card.className = 'log-card';
368
- card.setAttribute('data-id', id);
369
- card.innerHTML =
370
- '<div class="log-card-head">' +
371
- '<span class="method">' + escapeHtml(log.method) + '</span>' +
372
- '<span class="path" title="' + escapeAttr(log.path) + '">' + escapeHtml(log.path) + '</span>' +
373
- '<span class="status">' + escapeHtml(String(log.status)) + '</span>' +
374
- '<span class="ms">' + escapeHtml(String(log.duration_ms != null ? log.duration_ms : '')) + ' ms</span>' +
375
- '<button type="button" class="btn-toggle">Show</button>' +
376
- '</div>';
377
- wireCardHead(card, id);
378
- logList.insertBefore(card, logList.firstChild);
379
- if (focusedId === null) {
380
- editing = false;
381
- respIsReplay = false;
382
- paint();
383
- }
384
- return card;
385
- }
386
- function clearAll() {
387
- logList.innerHTML = '';
388
- originals = {};
389
- latestId = null;
390
- focusedId = null;
391
- editing = false;
392
- respIsReplay = false;
393
- paint();
394
- }
395
- function loadHistory() {
396
- fetch('/logs').then(function (res) { return res.json(); }).then(function (logs) {
397
- if (!Array.isArray(logs) || !logs.length) return;
398
- var i;
399
- for (i = 0; i < logs.length; i++) appendLogCard(logs[i]);
400
- }).catch(function () {});
401
- }
402
-
403
- btnLatest.onclick = function () {
404
- focusedId = null;
405
- editing = false;
406
- respIsReplay = false;
407
- paint();
408
- };
409
- btnModReplay.onclick = function () {
410
- var id = effectiveId();
411
- if (!id || !originals[id]) return;
412
- if (btnModReplay.textContent === 'Modify') {
413
- editing = true;
414
- paint();
415
- return;
416
- }
417
- var payload;
418
- try {
419
- payload = getPayloadFromPanel();
420
- } catch (err) {
421
- respIsReplay = true;
422
- respLabel.textContent = 'Response';
423
- respSlot.innerHTML = '<div class="err-box">' + escapeHtml(err.message) + '</div>';
424
- return;
425
- }
426
- if (typeof payload.method !== 'string' || !payload.method.trim()) {
427
- respIsReplay = true;
428
- respSlot.innerHTML = '<div class="err-box">method required</div>';
429
- return;
430
- }
431
- if (typeof payload.path !== 'string') {
432
- respIsReplay = true;
433
- respSlot.innerHTML = '<div class="err-box">path required</div>';
434
- return;
435
- }
436
- btnModReplay.disabled = true;
437
- respIsReplay = true;
438
- respLabel.textContent = 'Response (replay)';
439
- respSlot.innerHTML = '<p style="color:var(--muted);margin:0">…</p>';
440
- fetch('/replay', {
441
- method: 'POST',
442
- headers: { 'Content-Type': 'application/json' },
443
- body: JSON.stringify({
444
- method: payload.method,
445
- path: payload.path,
446
- headers: payload.headers || {},
447
- body: payload.body != null ? payload.body : ''
448
- })
449
- }).then(function (res) { return res.json(); })
450
- .then(function (data) {
451
- respSlot.innerHTML = renderReplayKv(data);
452
- })
453
- .catch(function (err) {
454
- respSlot.innerHTML = '<div class="err-box">' + escapeHtml(String(err)) + '</div>';
455
- })
456
- .finally(function () {
457
- btnModReplay.disabled = false;
458
- });
459
- };
460
- btnResetReq.onclick = function () {
461
- var id = effectiveId();
462
- if (!id || !originals[id]) return;
463
- editing = false;
464
- respIsReplay = false;
465
- paint();
466
- };
467
- reqSlot.addEventListener('input', function () {
468
- syncModReplayBtn();
469
- });
470
-
471
- loadHistory();
472
- paint();
473
-
474
- ws.onmessage = function (event) {
475
- appendLogCard(JSON.parse(event.data));
476
- };
477
-
478
- clearAllBtn.onclick = clearAll;
479
- })();
480
- </script>
481
- </body>
482
- </html>