repoburg 1.3.255 → 1.3.256
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/daemon/dist/index.js +61 -3
- package/daemon/dist/staticServing.js +2 -2
- package/daemon/dist/tabtape/api/follow.js +174 -0
- package/daemon/dist/tabtape/api/http.js +221 -0
- package/daemon/dist/tabtape/api/record.js +149 -0
- package/daemon/dist/tabtape/api/router.js +519 -0
- package/daemon/dist/tabtape/api/router.spec.js +604 -0
- package/daemon/dist/tabtape/api/static.js +70 -0
- package/daemon/dist/tabtape/api/ws.js +166 -0
- package/daemon/dist/tabtape/buffer/buffer.js +347 -0
- package/daemon/dist/tabtape/buffer/buffer.spec.js +137 -0
- package/daemon/dist/tabtape/config.js +108 -0
- package/daemon/dist/tabtape/e2e-server.js +96 -0
- package/daemon/dist/tabtape/errors.js +77 -0
- package/daemon/dist/tabtape/export/export.js +331 -0
- package/daemon/dist/tabtape/export/export.spec.js +262 -0
- package/daemon/dist/tabtape/har/gostd.js +395 -0
- package/daemon/dist/tabtape/har/gostd.spec.js +139 -0
- package/daemon/dist/tabtape/har/har.js +582 -0
- package/daemon/dist/tabtape/har/har.spec.js +325 -0
- package/daemon/dist/tabtape/index.js +134 -0
- package/daemon/dist/tabtape/index.spec.js +104 -0
- package/daemon/dist/tabtape/lock.js +67 -0
- package/daemon/dist/tabtape/log.js +32 -0
- package/daemon/dist/tabtape/mcp/definitions.js +362 -0
- package/daemon/dist/tabtape/mcp/gofmt.js +227 -0
- package/daemon/dist/tabtape/mcp/params.js +345 -0
- package/daemon/dist/tabtape/mcp/params.spec.js +172 -0
- package/daemon/dist/tabtape/mcp/recordLink.js +122 -0
- package/daemon/dist/tabtape/mcp/server.js +411 -0
- package/daemon/dist/tabtape/mcp/server.spec.js +722 -0
- package/daemon/dist/tabtape/mcp/tools.js +1085 -0
- package/daemon/dist/tabtape/query/format.js +195 -0
- package/daemon/dist/tabtape/query/network.js +133 -0
- package/daemon/dist/tabtape/query/perf.js +93 -0
- package/daemon/dist/tabtape/query/query.spec.js +233 -0
- package/daemon/dist/tabtape/query/reader.js +602 -0
- package/daemon/dist/tabtape/query/types.js +274 -0
- package/daemon/dist/tabtape/redact.js +37 -0
- package/daemon/dist/tabtape/repro/lang.js +95 -0
- package/daemon/dist/tabtape/repro/repro.js +422 -0
- package/daemon/dist/tabtape/repro/repro.spec.js +210 -0
- package/daemon/dist/tabtape/sink.js +24 -0
- package/daemon/dist/tabtape/store/record.js +99 -0
- package/daemon/dist/tabtape/store/segment.js +267 -0
- package/daemon/dist/tabtape/store/store.js +846 -0
- package/daemon/dist/tabtape/store/store.spec.js +485 -0
- package/daemon/dist/tabtape/testing/querytest.js +119 -0
- package/daemon/dist/tabtape/testing/tmp.js +33 -0
- package/package.json +3 -2
- package/tabtape/extension/dist/background.js +8 -0
- package/tabtape/extension/dist/background.js.map +7 -0
- package/tabtape/extension/dist/bridge.js +3 -0
- package/tabtape/extension/dist/bridge.js.map +7 -0
- package/tabtape/extension/dist/icons/128.png +0 -0
- package/tabtape/extension/dist/icons/16.png +0 -0
- package/tabtape/extension/dist/icons/32.png +0 -0
- package/tabtape/extension/dist/icons/48.png +0 -0
- package/tabtape/extension/dist/manifest.json +28 -0
- package/tabtape/extension/dist/popup.css +62 -0
- package/tabtape/extension/dist/popup.html +34 -0
- package/tabtape/extension/dist/popup.js +4 -0
- package/tabtape/extension/dist/popup.js.map +7 -0
- package/tabtape/extension/dist/recorder.js +133 -0
- package/tabtape/extension/dist/recorder.js.map +1 -0
- package/tabtape/extension/dist/state.js +2 -0
- package/tabtape/extension/dist/state.js.map +1 -0
- package/tabtape/playwright/mcp.mjs +58 -0
- package/tabtape/playwright/tabtape.d.mts +37 -0
- package/tabtape/playwright/tabtape.mjs +146 -0
- package/tabtape/ui/dist/app/assets/index-Canv3Ljk.css +1 -0
- package/tabtape/ui/dist/app/assets/index-iWNSSrAE.js +145 -0
- package/tabtape/ui/dist/app/index.html +14 -0
- package/tabtape/ui/dist/export/viewer.css +2 -0
- package/tabtape/ui/dist/export/viewer.js +143 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TABTAPE_MOUNT = exports.TABTAPE_VERSION = void 0;
|
|
7
|
+
exports.parseSize = parseSize;
|
|
8
|
+
exports.formatSize = formatSize;
|
|
9
|
+
exports.defaultDataDir = defaultDataDir;
|
|
10
|
+
exports.loadConfig = loadConfig;
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const redact_1 = require("./redact");
|
|
14
|
+
/** Version of the tabtape component (extension, replay UI, server API). */
|
|
15
|
+
exports.TABTAPE_VERSION = '0.5.0';
|
|
16
|
+
/** URL prefix of everything tabtape serves on the daemon's port. */
|
|
17
|
+
exports.TABTAPE_MOUNT = '/tabtape';
|
|
18
|
+
const UNITS = [
|
|
19
|
+
['GIB', 1 << 30],
|
|
20
|
+
['MIB', 1 << 20],
|
|
21
|
+
['KIB', 1 << 10],
|
|
22
|
+
['GB', 1 << 30],
|
|
23
|
+
['MB', 1 << 20],
|
|
24
|
+
['KB', 1 << 10],
|
|
25
|
+
['G', 1 << 30],
|
|
26
|
+
['M', 1 << 20],
|
|
27
|
+
['K', 1 << 10],
|
|
28
|
+
['B', 1],
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Parses sizes like "500MB", "16M", "1GiB" or "4096". Units are binary:
|
|
32
|
+
* 1MB = 1024*1024 bytes.
|
|
33
|
+
*/
|
|
34
|
+
function parseSize(s) {
|
|
35
|
+
let t = s.trim().toUpperCase();
|
|
36
|
+
let mult = 1;
|
|
37
|
+
for (const [suffix, m] of UNITS) {
|
|
38
|
+
if (t.endsWith(suffix)) {
|
|
39
|
+
t = t.slice(0, -suffix.length).trim();
|
|
40
|
+
mult = m;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const n = t === '' ? NaN : Number(t);
|
|
45
|
+
if (!Number.isFinite(n))
|
|
46
|
+
throw new Error(`invalid size "${s}"`);
|
|
47
|
+
if (n <= 0)
|
|
48
|
+
throw new Error('size must be positive');
|
|
49
|
+
return Math.trunc(n * mult);
|
|
50
|
+
}
|
|
51
|
+
function formatSize(n) {
|
|
52
|
+
if (n >= 1 << 30)
|
|
53
|
+
return `${(n / (1 << 30)).toFixed(1)}GB`;
|
|
54
|
+
if (n >= 1 << 20)
|
|
55
|
+
return `${(n / (1 << 20)).toFixed(1)}MB`;
|
|
56
|
+
if (n >= 1 << 10)
|
|
57
|
+
return `${(n / (1 << 10)).toFixed(1)}KB`;
|
|
58
|
+
return `${n}B`;
|
|
59
|
+
}
|
|
60
|
+
/** Default data directory: ~/.repoburg/tabtape (next to the daemon's other state). */
|
|
61
|
+
function defaultDataDir(home = os_1.default.homedir()) {
|
|
62
|
+
return path_1.default.join(home, '.repoburg', 'tabtape');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads the configuration from the environment:
|
|
66
|
+
* REPOBURG_TABTAPE_DATA data directory (default ~/.repoburg/tabtape)
|
|
67
|
+
* REPOBURG_TABTAPE_MAX_SIZE ring buffer cap (default 500MB)
|
|
68
|
+
* REPOBURG_TABTAPE_SEGMENT_SIZE segment size (default 16MB)
|
|
69
|
+
* REPOBURG_TABTAPE_COMPRESS gzip (default) or off
|
|
70
|
+
* REPOBURG_TABTAPE_BUFFER_MAX_SIZE buffer mode space (default 100MB)
|
|
71
|
+
* REPOBURG_TABTAPE_REDACT_HEADERS headers hidden in MCP results and exports
|
|
72
|
+
* REPOBURG_TABTAPE_MCP_SHOW_SECRETS 1 shows those headers to MCP clients
|
|
73
|
+
* REPOBURG_TABTAPE_MCP_TOKEN bearer token required by /tabtape/mcp
|
|
74
|
+
*/
|
|
75
|
+
function loadConfig(env = process.env, home = os_1.default.homedir()) {
|
|
76
|
+
const get = (key, fallback) => {
|
|
77
|
+
const v = env[key];
|
|
78
|
+
return v !== undefined && v !== '' ? v : fallback;
|
|
79
|
+
};
|
|
80
|
+
const size = (key, fallback) => {
|
|
81
|
+
try {
|
|
82
|
+
return parseSize(get(key, fallback));
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new Error(`${key}: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const maxBytes = size('REPOBURG_TABTAPE_MAX_SIZE', '500MB');
|
|
89
|
+
let segmentBytes = size('REPOBURG_TABTAPE_SEGMENT_SIZE', '16MB');
|
|
90
|
+
const bufferBytes = size('REPOBURG_TABTAPE_BUFFER_MAX_SIZE', '100MB');
|
|
91
|
+
if (segmentBytes > maxBytes / 4) {
|
|
92
|
+
segmentBytes = Math.max(Math.floor(maxBytes / 4), 1); // keep eviction granular for small caps
|
|
93
|
+
}
|
|
94
|
+
const compress = get('REPOBURG_TABTAPE_COMPRESS', 'gzip');
|
|
95
|
+
if (compress !== 'off' && compress !== 'gzip') {
|
|
96
|
+
throw new Error(`REPOBURG_TABTAPE_COMPRESS: "${compress}" is neither "off" nor "gzip"`);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
dataDir: get('REPOBURG_TABTAPE_DATA', defaultDataDir(home)),
|
|
100
|
+
maxBytes,
|
|
101
|
+
segmentBytes,
|
|
102
|
+
compress,
|
|
103
|
+
bufferBytes,
|
|
104
|
+
redactHeaders: get('REPOBURG_TABTAPE_REDACT_HEADERS', redact_1.DEFAULT_REDACT_HEADERS),
|
|
105
|
+
showSecrets: env.REPOBURG_TABTAPE_MCP_SHOW_SECRETS === '1',
|
|
106
|
+
mcpToken: env.REPOBURG_TABTAPE_MCP_TOKEN ?? '',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
/**
|
|
8
|
+
* Test harness: serves tabtape alone, mounted exactly as the daemon mounts
|
|
9
|
+
* it, on an address of its own — for tabtape/e2e and UI development without
|
|
10
|
+
* the daemon (whose port, pm2 state and data belong to the real install).
|
|
11
|
+
*
|
|
12
|
+
* node daemon/dist/tabtape/e2e-server.js --addr 127.0.0.1:7787 --data <dir>
|
|
13
|
+
* [--max-size 500MB] [--segment-size 16MB] [--compress gzip|off]
|
|
14
|
+
* [--buffer-max-size 100MB] [--redact-headers a,b] [--mcp-show-secrets]
|
|
15
|
+
*
|
|
16
|
+
* Production never runs this: the daemon serves tabtape on its own port.
|
|
17
|
+
*/
|
|
18
|
+
const express_1 = __importDefault(require("express"));
|
|
19
|
+
const http_1 = __importDefault(require("http"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const config_1 = require("./config");
|
|
22
|
+
const index_1 = require("./index");
|
|
23
|
+
const FLAGS = {
|
|
24
|
+
data: 'REPOBURG_TABTAPE_DATA',
|
|
25
|
+
'max-size': 'REPOBURG_TABTAPE_MAX_SIZE',
|
|
26
|
+
'segment-size': 'REPOBURG_TABTAPE_SEGMENT_SIZE',
|
|
27
|
+
compress: 'REPOBURG_TABTAPE_COMPRESS',
|
|
28
|
+
'buffer-max-size': 'REPOBURG_TABTAPE_BUFFER_MAX_SIZE',
|
|
29
|
+
'redact-headers': 'REPOBURG_TABTAPE_REDACT_HEADERS',
|
|
30
|
+
};
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
let addr = '127.0.0.1:7787';
|
|
33
|
+
const env = { ...process.env };
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
const m = /^--?([a-z-]+)(?:=(.*))?$/.exec(arg);
|
|
37
|
+
if (!m)
|
|
38
|
+
throw new Error(`unexpected argument ${arg}`);
|
|
39
|
+
const name = m[1];
|
|
40
|
+
if (name === 'mcp-show-secrets') {
|
|
41
|
+
env.REPOBURG_TABTAPE_MCP_SHOW_SECRETS = '1';
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const value = m[2] ?? argv[++i];
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
throw new Error(`--${name} needs a value`);
|
|
47
|
+
if (name === 'addr')
|
|
48
|
+
addr = value;
|
|
49
|
+
else if (FLAGS[name])
|
|
50
|
+
env[FLAGS[name]] = value;
|
|
51
|
+
else
|
|
52
|
+
throw new Error(`unknown flag --${name}`);
|
|
53
|
+
}
|
|
54
|
+
return { addr, env };
|
|
55
|
+
}
|
|
56
|
+
async function main() {
|
|
57
|
+
const { addr, env } = parseArgs(process.argv.slice(2));
|
|
58
|
+
const i = addr.lastIndexOf(':');
|
|
59
|
+
const host = i > 0 ? addr.slice(0, i).replace(/^\[|\]$/g, '') : '127.0.0.1';
|
|
60
|
+
const port = Number(i >= 0 ? addr.slice(i + 1) : addr);
|
|
61
|
+
const config = (0, config_1.loadConfig)(env);
|
|
62
|
+
const baseHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host.includes(':') ? `[${host}]` : host;
|
|
63
|
+
const tabtape = (0, index_1.createTabtape)({
|
|
64
|
+
baseURL: `http://${baseHost}:${port}/tabtape`,
|
|
65
|
+
uiDist: (0, index_1.resolveTabtapeUiDist)(path_1.default.join(__dirname, '..')),
|
|
66
|
+
config,
|
|
67
|
+
});
|
|
68
|
+
const app = (0, express_1.default)();
|
|
69
|
+
app.use(tabtape.mount, tabtape.router);
|
|
70
|
+
app.use((_req, res) => {
|
|
71
|
+
res.status(404).type('text/plain').send(`tabtape is served under ${tabtape.mount}/\n`);
|
|
72
|
+
});
|
|
73
|
+
const server = http_1.default.createServer(app);
|
|
74
|
+
server.on('upgrade', (req, socket, head) => {
|
|
75
|
+
if (tabtape.handlesUpgrade(req))
|
|
76
|
+
tabtape.handleUpgrade(req, socket, head);
|
|
77
|
+
else
|
|
78
|
+
socket.destroy();
|
|
79
|
+
});
|
|
80
|
+
await new Promise((resolve, reject) => {
|
|
81
|
+
server.once('error', reject);
|
|
82
|
+
server.listen(port, host, resolve);
|
|
83
|
+
});
|
|
84
|
+
console.log(`tabtape test server listening on http://${baseHost}:${port}/tabtape/ (data: ${config.dataDir})`);
|
|
85
|
+
const shutdown = async () => {
|
|
86
|
+
server.close();
|
|
87
|
+
await tabtape.close();
|
|
88
|
+
process.exit(0);
|
|
89
|
+
};
|
|
90
|
+
process.on('SIGINT', () => void shutdown());
|
|
91
|
+
process.on('SIGTERM', () => void shutdown());
|
|
92
|
+
}
|
|
93
|
+
main().catch((err) => {
|
|
94
|
+
console.error('tabtape test server:', err instanceof Error ? err.message : err);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Error types shared by the tabtape store, buffers and queries. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.NoViewerError = exports.EmptyRangeError = exports.BufferEmptyError = exports.BufferNotFoundError = exports.GoneError = exports.RecordError = exports.InvalidIDError = exports.NotFoundError = void 0;
|
|
5
|
+
exports.isNotExist = isNotExist;
|
|
6
|
+
exports.errorMessage = errorMessage;
|
|
7
|
+
/** A session that does not exist (or was deleted by the ring buffer). */
|
|
8
|
+
class NotFoundError extends Error {
|
|
9
|
+
constructor(message = 'session not found') {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'NotFoundError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.NotFoundError = NotFoundError;
|
|
15
|
+
/** An id that is unsafe as a directory name. */
|
|
16
|
+
class InvalidIDError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super('invalid session id');
|
|
19
|
+
this.name = 'InvalidIDError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.InvalidIDError = InvalidIDError;
|
|
23
|
+
/** A record line that cannot be stored. */
|
|
24
|
+
class RecordError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'RecordError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.RecordError = RecordError;
|
|
31
|
+
/** A line whose segment was deleted by the ring buffer after a view was built. */
|
|
32
|
+
class GoneError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('record deleted by the ring buffer');
|
|
35
|
+
this.name = 'GoneError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.GoneError = GoneError;
|
|
39
|
+
/** An unknown buffer (buffer mode). */
|
|
40
|
+
class BufferNotFoundError extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super('buffer not found');
|
|
43
|
+
this.name = 'BufferNotFoundError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.BufferNotFoundError = BufferNotFoundError;
|
|
47
|
+
/** A buffer that holds nothing to save yet. */
|
|
48
|
+
class BufferEmptyError extends Error {
|
|
49
|
+
constructor() {
|
|
50
|
+
super('the buffer is empty');
|
|
51
|
+
this.name = 'BufferEmptyError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.BufferEmptyError = BufferEmptyError;
|
|
55
|
+
/** A from/to range whose end is not after its start. */
|
|
56
|
+
class EmptyRangeError extends Error {
|
|
57
|
+
constructor() {
|
|
58
|
+
super('empty range: to must be after from');
|
|
59
|
+
this.name = 'EmptyRangeError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.EmptyRangeError = EmptyRangeError;
|
|
63
|
+
/** The offline viewer that HTML exports embed was not built. */
|
|
64
|
+
class NoViewerError extends Error {
|
|
65
|
+
constructor() {
|
|
66
|
+
super('the export viewer is not built: run npm run build in tabtape/');
|
|
67
|
+
this.name = 'NoViewerError';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.NoViewerError = NoViewerError;
|
|
71
|
+
/** Node's "no such file or directory". */
|
|
72
|
+
function isNotExist(err) {
|
|
73
|
+
return err?.code === 'ENOENT';
|
|
74
|
+
}
|
|
75
|
+
function errorMessage(err) {
|
|
76
|
+
return err instanceof Error ? err.message : String(err);
|
|
77
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.writeExport = writeExport;
|
|
7
|
+
/**
|
|
8
|
+
* Writes a recording as one self-contained HTML file: the offline viewer
|
|
9
|
+
* (built from tabtape/ui, see vite.export.config.ts) with the session's lines
|
|
10
|
+
* embedded as gzip + base64. The file opens from disk in any current browser;
|
|
11
|
+
* images and fonts still load from the recorded site.
|
|
12
|
+
*
|
|
13
|
+
* The file is streamed: lines are read in file order, compressed, encoded and
|
|
14
|
+
* handed to the sink in chunks of tens of kilobytes, waiting whenever the
|
|
15
|
+
* sink returns a promise. A session of any size exports in bounded memory.
|
|
16
|
+
*/
|
|
17
|
+
const zlib_1 = __importDefault(require("zlib"));
|
|
18
|
+
const errors_1 = require("../errors");
|
|
19
|
+
const reader_1 = require("../query/reader");
|
|
20
|
+
const types_1 = require("../query/types");
|
|
21
|
+
/** Uncompressed bytes handed to gzip at once. */
|
|
22
|
+
const BATCH_BYTES = 64 * 1024;
|
|
23
|
+
/** Compressed bytes collected before they are encoded and written. */
|
|
24
|
+
const FLUSH_BYTES = 48 * 1024;
|
|
25
|
+
/** Beyond any timestamp: an open end of the range. */
|
|
26
|
+
const FAR = 2 ** 62;
|
|
27
|
+
/**
|
|
28
|
+
* Writes the self-contained HTML file for v to the sink; returns the number
|
|
29
|
+
* of bytes written. Throws NoViewerError when assets.js is empty. Errors of
|
|
30
|
+
* the sink or of reading the session are thrown as they are.
|
|
31
|
+
*/
|
|
32
|
+
async function writeExport(sink, v, assets, opts) {
|
|
33
|
+
const js = bytesOf(assets.js);
|
|
34
|
+
if (js.length === 0)
|
|
35
|
+
throw new errors_1.NoViewerError();
|
|
36
|
+
const now = opts.now ?? Date.now;
|
|
37
|
+
const out = new Counter(sink);
|
|
38
|
+
const s = v.session;
|
|
39
|
+
const redacted = opts.secrets ? [] : opts.redact.list();
|
|
40
|
+
const [from, to] = selectLines(v, opts);
|
|
41
|
+
const optFrom = opts.from ?? 0;
|
|
42
|
+
const optTo = opts.to ?? 0;
|
|
43
|
+
const meta = {
|
|
44
|
+
id: s.id,
|
|
45
|
+
...(s.title ? { title: s.title } : {}),
|
|
46
|
+
...(s.url ? { url: s.url } : {}),
|
|
47
|
+
startedAt: s.startedAt,
|
|
48
|
+
...(s.userAgent ? { userAgent: s.userAgent } : {}),
|
|
49
|
+
...(s.truncated ? { truncated: true } : {}),
|
|
50
|
+
exportedAt: now(),
|
|
51
|
+
version: opts.version,
|
|
52
|
+
...(redacted.length > 0 ? { redactedHeaders: redacted } : {}),
|
|
53
|
+
...(opts.stripBodies ? { bodiesRemoved: true } : {}),
|
|
54
|
+
...(opts.stripConsoleArgs ? { consoleArgsRemoved: true } : {}),
|
|
55
|
+
...(optFrom > 0 || optTo > 0 ? { range: { from: Math.max(optFrom, 0), to: v.offset(to) } } : {}),
|
|
56
|
+
};
|
|
57
|
+
const title = s.title || s.id;
|
|
58
|
+
await out.put('<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n' +
|
|
59
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
60
|
+
`<meta name="generator" content="tabtape ${escapeHTML(opts.version)}">\n` +
|
|
61
|
+
`<title>${escapeHTML(title)} · tabtape recording</title>\n<style>`);
|
|
62
|
+
await out.put(escapeClose(bytesOf(assets.css), STYLE_CLOSE));
|
|
63
|
+
await out.put('</style>\n</head>\n<body>\n' +
|
|
64
|
+
'<div id="root"><div class="loading"><div class="spinner"></div>Opening the recording…</div></div>\n' +
|
|
65
|
+
`<script id="tabtape-meta" type="application/json">${scriptJSON(meta)}</script>\n` +
|
|
66
|
+
'<script id="tabtape-data" type="application/octet-stream">');
|
|
67
|
+
const data = new EmbeddedData((b) => out.put(b));
|
|
68
|
+
try {
|
|
69
|
+
await v.scan((l, raw) => (included(l, from, to) ? data.write(transform(l, raw, opts)) : undefined));
|
|
70
|
+
await data.end();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
data.destroy();
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
await out.put('</script>\n<script>');
|
|
77
|
+
await out.put(escapeClose(js, SCRIPT_CLOSE));
|
|
78
|
+
await out.put('</script>\n</body>\n</html>\n');
|
|
79
|
+
return out.n;
|
|
80
|
+
}
|
|
81
|
+
/** Counts what the sink accepted and waits when it asks to. */
|
|
82
|
+
class Counter {
|
|
83
|
+
constructor(sink) {
|
|
84
|
+
this.sink = sink;
|
|
85
|
+
this.n = 0;
|
|
86
|
+
}
|
|
87
|
+
async put(chunk) {
|
|
88
|
+
const b = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
89
|
+
if (b.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
const pending = this.sink(b);
|
|
92
|
+
this.n += b.length;
|
|
93
|
+
if (pending)
|
|
94
|
+
await pending;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The embedded recording: lines → gzip → base64 → put. Lines are batched for
|
|
99
|
+
* zlib, and compressed output is encoded in multiples of 3 bytes, so the
|
|
100
|
+
* pieces concatenate to the base64 of one gzip stream. Each batch is
|
|
101
|
+
* compressed and its output written before more lines are taken.
|
|
102
|
+
*/
|
|
103
|
+
class EmbeddedData {
|
|
104
|
+
constructor(put) {
|
|
105
|
+
this.put = put;
|
|
106
|
+
this.gz = zlib_1.default.createGzip();
|
|
107
|
+
this.batch = Buffer.allocUnsafe(BATCH_BYTES);
|
|
108
|
+
this.batchLen = 0;
|
|
109
|
+
this.compressed = [];
|
|
110
|
+
this.compressedLen = 0;
|
|
111
|
+
/** Compressed bytes not encoded yet: fewer than 3. */
|
|
112
|
+
this.carry = Buffer.alloc(0);
|
|
113
|
+
this.gz.on('data', (chunk) => {
|
|
114
|
+
this.compressed.push(chunk);
|
|
115
|
+
this.compressedLen += chunk.length;
|
|
116
|
+
});
|
|
117
|
+
// Failures reach the write callback or end(); this keeps an 'error'
|
|
118
|
+
// event from being unhandled.
|
|
119
|
+
this.gz.on('error', () => { });
|
|
120
|
+
}
|
|
121
|
+
/** Adds a line; returns a promise when the caller must wait for the batch to be written. */
|
|
122
|
+
write(line) {
|
|
123
|
+
// Lines are copied, so no buffer of scan is kept beyond its call.
|
|
124
|
+
if (this.batchLen + line.length + 1 > this.batch.length)
|
|
125
|
+
return this.writeSlow(line);
|
|
126
|
+
line.copy(this.batch, this.batchLen);
|
|
127
|
+
this.batchLen += line.length;
|
|
128
|
+
this.batch[this.batchLen++] = 0x0a;
|
|
129
|
+
}
|
|
130
|
+
async writeSlow(line) {
|
|
131
|
+
await this.flushBatch();
|
|
132
|
+
if (line.length + 1 > this.batch.length) {
|
|
133
|
+
await this.compress(Buffer.concat([line, NEWLINE]));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
line.copy(this.batch, 0);
|
|
137
|
+
this.batchLen = line.length;
|
|
138
|
+
this.batch[this.batchLen++] = 0x0a;
|
|
139
|
+
}
|
|
140
|
+
async flushBatch() {
|
|
141
|
+
if (this.batchLen === 0)
|
|
142
|
+
return;
|
|
143
|
+
const chunk = this.batch.subarray(0, this.batchLen);
|
|
144
|
+
this.batch = Buffer.allocUnsafe(BATCH_BYTES); // zlib keeps chunk until it is processed
|
|
145
|
+
this.batchLen = 0;
|
|
146
|
+
await this.compress(chunk);
|
|
147
|
+
}
|
|
148
|
+
async compress(chunk) {
|
|
149
|
+
await new Promise((resolve, reject) => {
|
|
150
|
+
this.gz.write(chunk, (err) => (err ? reject(err) : resolve()));
|
|
151
|
+
});
|
|
152
|
+
if (this.compressedLen >= FLUSH_BYTES)
|
|
153
|
+
await this.encode(false);
|
|
154
|
+
}
|
|
155
|
+
/** Encodes and writes the compressed output; all of it, padded, when final. */
|
|
156
|
+
async encode(final) {
|
|
157
|
+
const data = Buffer.concat([this.carry, ...this.compressed]);
|
|
158
|
+
this.compressed = [];
|
|
159
|
+
this.compressedLen = 0;
|
|
160
|
+
const n = final ? data.length : data.length - (data.length % 3);
|
|
161
|
+
this.carry = Buffer.from(data.subarray(n));
|
|
162
|
+
if (n > 0)
|
|
163
|
+
await this.put(Buffer.from(data.toString('base64', 0, n), 'latin1'));
|
|
164
|
+
}
|
|
165
|
+
/** Writes the rest and the gzip trailer. */
|
|
166
|
+
async end() {
|
|
167
|
+
await this.flushBatch();
|
|
168
|
+
await new Promise((resolve, reject) => {
|
|
169
|
+
this.gz.once('end', resolve);
|
|
170
|
+
this.gz.once('error', reject);
|
|
171
|
+
this.gz.end();
|
|
172
|
+
});
|
|
173
|
+
await this.encode(true);
|
|
174
|
+
}
|
|
175
|
+
destroy() {
|
|
176
|
+
this.gz.destroy();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const NEWLINE = Buffer.from('\n');
|
|
180
|
+
/**
|
|
181
|
+
* Whether a line belongs in the file. A WebSocket connection's record
|
|
182
|
+
* carries its open time; it is included while the connection was open during
|
|
183
|
+
* the range, so the range's messages keep their connection.
|
|
184
|
+
*/
|
|
185
|
+
function included(l, from, to) {
|
|
186
|
+
if (l.ts >= from && l.ts <= to)
|
|
187
|
+
return true;
|
|
188
|
+
const n = l.net;
|
|
189
|
+
if (n && (0, types_1.isWebSocket)(n) && l.ts < from)
|
|
190
|
+
return n.pending || n.closedAt === 0 || Math.trunc(n.closedAt) >= from;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
/** The timestamps of the first and last line to include. */
|
|
194
|
+
function selectLines(v, o) {
|
|
195
|
+
let from = -FAR;
|
|
196
|
+
let to = FAR;
|
|
197
|
+
const optFrom = o.from ?? 0;
|
|
198
|
+
const optTo = o.to ?? 0;
|
|
199
|
+
if (optTo > 0)
|
|
200
|
+
to = v.start + optTo;
|
|
201
|
+
if (optFrom <= 0)
|
|
202
|
+
return [from, to];
|
|
203
|
+
// Start at the last Meta + full snapshot pair at or before the range,
|
|
204
|
+
// else at the first one.
|
|
205
|
+
const target = v.start + optFrom;
|
|
206
|
+
const rr = v.lines.filter((l) => l.type === 'rrweb');
|
|
207
|
+
let found = false;
|
|
208
|
+
for (let i = 0; i + 1 < rr.length; i++) {
|
|
209
|
+
if (rr[i].rr === reader_1.RR_META && rr[i + 1].rr === reader_1.RR_FULL_SNAPSHOT) {
|
|
210
|
+
if (rr[i].ts > target && found)
|
|
211
|
+
break;
|
|
212
|
+
from = rr[i].ts;
|
|
213
|
+
found = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!found)
|
|
217
|
+
from = target;
|
|
218
|
+
return [from, to];
|
|
219
|
+
}
|
|
220
|
+
/** Applies the options to one line. Lines that need no change are returned as they are. */
|
|
221
|
+
function transform(l, raw, o) {
|
|
222
|
+
if (l.type === 'network' && (!o.secrets || o.stripBodies)) {
|
|
223
|
+
return editData(raw, (d) => {
|
|
224
|
+
if (!o.secrets) {
|
|
225
|
+
for (const key of ['reqHeaders', 'resHeaders'])
|
|
226
|
+
redactHeaders(d, key, o.redact);
|
|
227
|
+
}
|
|
228
|
+
if (o.stripBodies) {
|
|
229
|
+
for (const side of ['req', 'res']) {
|
|
230
|
+
if (has(d, side + 'Body')) {
|
|
231
|
+
delete d[side + 'Body'];
|
|
232
|
+
delete d[side + 'BodyTruncated'];
|
|
233
|
+
d[side + 'BodyNote'] = 'removed from this export';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (l.type === 'ws' && o.stripBodies) {
|
|
240
|
+
// WebSocket messages are bodies too.
|
|
241
|
+
return editData(raw, (d) => {
|
|
242
|
+
if (has(d, 'data')) {
|
|
243
|
+
delete d.data;
|
|
244
|
+
delete d.truncated;
|
|
245
|
+
d.dataRemoved = true;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (l.type === 'console' && o.stripConsoleArgs) {
|
|
250
|
+
return editData(raw, (d) => {
|
|
251
|
+
delete d.args;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return raw;
|
|
255
|
+
}
|
|
256
|
+
const has = (d, key) => Object.prototype.hasOwnProperty.call(d, key);
|
|
257
|
+
/** Replaces sensitive header values, when the field is a map of strings; anything else is left alone. */
|
|
258
|
+
function redactHeaders(d, key, rd) {
|
|
259
|
+
const h = d[key];
|
|
260
|
+
if (!(0, types_1.isObj)(h))
|
|
261
|
+
return; // missing, null or not the expected shape
|
|
262
|
+
const m = {};
|
|
263
|
+
for (const [k, v] of Object.entries(h)) {
|
|
264
|
+
if (typeof v === 'string')
|
|
265
|
+
m[k] = v;
|
|
266
|
+
else if (v === null)
|
|
267
|
+
m[k] = ''; // Go decodes null into a string as ""
|
|
268
|
+
else
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
d[key] = rd.apply(m);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Decodes a line's "data" object, lets fn change it and re-encodes the line
|
|
275
|
+
* with the envelope's fields in their order: sid, ts, type, data. Lines that
|
|
276
|
+
* are not such an object are kept as they are.
|
|
277
|
+
*/
|
|
278
|
+
function editData(raw, fn) {
|
|
279
|
+
let env;
|
|
280
|
+
try {
|
|
281
|
+
env = JSON.parse(raw.toString('utf8'));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return raw;
|
|
285
|
+
}
|
|
286
|
+
if (!(0, types_1.isObj)(env))
|
|
287
|
+
return raw;
|
|
288
|
+
const { sid, ts, type, data } = env;
|
|
289
|
+
if (!(0, types_1.isObj)(data))
|
|
290
|
+
return raw; // missing, null or not an object
|
|
291
|
+
if ((sid !== undefined && sid !== null && typeof sid !== 'string') || (type !== undefined && type !== null && typeof type !== 'string')) {
|
|
292
|
+
return raw;
|
|
293
|
+
}
|
|
294
|
+
fn(data);
|
|
295
|
+
const line = {
|
|
296
|
+
...(typeof sid === 'string' && sid !== '' ? { sid } : {}),
|
|
297
|
+
ts: ts === undefined ? null : ts,
|
|
298
|
+
type: typeof type === 'string' ? type : '',
|
|
299
|
+
data,
|
|
300
|
+
};
|
|
301
|
+
return Buffer.from(JSON.stringify(line), 'utf8');
|
|
302
|
+
}
|
|
303
|
+
function bytesOf(b) {
|
|
304
|
+
return typeof b === 'string' ? Buffer.from(b, 'utf8') : b;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* "</script" and "</style" in any case, which would end the element early.
|
|
308
|
+
* Like Go's (?i), "ſ" (U+017F, UTF-8 C5 BF) matches "s" too. The assets are
|
|
309
|
+
* matched as latin1 so their bytes pass through unchanged.
|
|
310
|
+
*/
|
|
311
|
+
const SCRIPT_CLOSE = /<\/((?:[sS]|\xc5\xbf)[cC][rR][iI][pP][tT])/g;
|
|
312
|
+
const STYLE_CLOSE = /<\/((?:[sS]|\xc5\xbf)[tT][yY][lL][eE])/g;
|
|
313
|
+
function escapeClose(b, re) {
|
|
314
|
+
const s = b.toString('latin1');
|
|
315
|
+
const escaped = s.replace(re, '<\\/$1');
|
|
316
|
+
return escaped === s ? b : Buffer.from(escaped, 'latin1');
|
|
317
|
+
}
|
|
318
|
+
const HTML_ESCAPES = { '&': '&', "'": ''', '<': '<', '>': '>', '"': '"' };
|
|
319
|
+
/** Go's html.EscapeString. */
|
|
320
|
+
function escapeHTML(s) {
|
|
321
|
+
return s.replace(/[&'<>"]/g, (c) => HTML_ESCAPES[c]);
|
|
322
|
+
}
|
|
323
|
+
/** What Go's json.Marshal escapes and JSON.stringify does not: <, >, &, U+2028 and U+2029. */
|
|
324
|
+
const SCRIPT_UNSAFE = new RegExp(`[<>&${String.fromCharCode(0x2028, 0x2029)}]`, 'g');
|
|
325
|
+
/**
|
|
326
|
+
* JSON that cannot end its script element, escaped like Go's json.Marshal.
|
|
327
|
+
* The escaped characters only occur inside strings.
|
|
328
|
+
*/
|
|
329
|
+
function scriptJSON(v) {
|
|
330
|
+
return JSON.stringify(v).replace(SCRIPT_UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
331
|
+
}
|