@phi-code-admin/camofox-browser 1.0.0 → 1.0.2

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 (53) hide show
  1. package/AGENTS.md +571 -571
  2. package/Dockerfile +86 -86
  3. package/LICENSE +21 -21
  4. package/README.md +691 -691
  5. package/camofox.config.json +10 -10
  6. package/lib/auth.js +134 -134
  7. package/lib/camoufox-executable.js +189 -189
  8. package/lib/config.js +153 -153
  9. package/lib/cookies.js +119 -119
  10. package/lib/downloads.js +168 -168
  11. package/lib/extract.js +74 -74
  12. package/lib/fly.js +54 -54
  13. package/lib/images.js +88 -88
  14. package/lib/inflight.js +16 -16
  15. package/lib/launcher.js +47 -47
  16. package/lib/macros.js +31 -31
  17. package/lib/metrics.js +184 -184
  18. package/lib/openapi.js +105 -105
  19. package/lib/persistence.js +89 -89
  20. package/lib/plugins.js +178 -175
  21. package/lib/proxy.js +277 -277
  22. package/lib/reporter.js +1102 -1102
  23. package/lib/request-utils.js +59 -59
  24. package/lib/resources.js +76 -76
  25. package/lib/snapshot.js +41 -41
  26. package/lib/tmp-cleanup.js +108 -108
  27. package/lib/tracing.js +137 -137
  28. package/openclaw.plugin.json +268 -268
  29. package/package.json +148 -148
  30. package/plugin.ts +758 -758
  31. package/plugins/persistence/AGENTS.md +37 -37
  32. package/plugins/persistence/README.md +48 -48
  33. package/plugins/persistence/index.js +124 -124
  34. package/plugins/vnc/AGENTS.md +42 -42
  35. package/plugins/vnc/README.md +165 -165
  36. package/plugins/vnc/apt.txt +7 -7
  37. package/plugins/vnc/index.js +142 -142
  38. package/plugins/vnc/spawn.js +8 -8
  39. package/plugins/vnc/vnc-launcher.js +64 -64
  40. package/plugins/vnc/vnc-watcher.sh +82 -82
  41. package/plugins/youtube/AGENTS.md +25 -25
  42. package/plugins/youtube/apt.txt +1 -1
  43. package/plugins/youtube/index.js +206 -206
  44. package/plugins/youtube/post-install.sh +5 -5
  45. package/plugins/youtube/youtube.js +301 -301
  46. package/run.sh +37 -37
  47. package/scripts/exec.js +8 -8
  48. package/scripts/generate-openapi.js +24 -24
  49. package/scripts/install-plugin-deps.sh +63 -63
  50. package/scripts/plugin.js +342 -342
  51. package/scripts/sync-version.js +25 -25
  52. package/server.js +6062 -6059
  53. package/tsconfig.json +12 -12
package/lib/downloads.js CHANGED
@@ -1,168 +1,168 @@
1
- /**
2
- * Download capture and DOM image extraction for camofox-browser.
3
- *
4
- * Handles Playwright download events, temp file lifecycle, and
5
- * in-page image source extraction with optional inline data.
6
- */
7
-
8
- import crypto from 'crypto';
9
- import path from 'path';
10
- import os from 'os';
11
- import fs from 'node:fs/promises';
12
-
13
- const MAX_DOWNLOAD_RECORDS_PER_TAB = 20;
14
- const MAX_DOWNLOAD_INLINE_BYTES = 20 * 1024 * 1024;
15
-
16
- function sanitizeFilename(value) {
17
- return String(value || 'download.bin')
18
- .replace(/[\\/:*?"<>|\u0000-\u001F]/g, '_')
19
- .trim()
20
- .slice(0, 200) || 'download.bin';
21
- }
22
-
23
- function guessMimeTypeFromName(value) {
24
- const normalized = String(value || '').toLowerCase();
25
- if (normalized.endsWith('.png')) return 'image/png';
26
- if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
27
- if (normalized.endsWith('.webp')) return 'image/webp';
28
- if (normalized.endsWith('.gif')) return 'image/gif';
29
- if (normalized.endsWith('.svg')) return 'image/svg+xml';
30
- return 'application/octet-stream';
31
- }
32
-
33
- async function removeDownloadFileIfPresent(record) {
34
- const filePath = record?.filePath;
35
- if (!filePath) return;
36
- await fs.unlink(filePath).catch(() => {});
37
- }
38
-
39
- async function trimTabDownloads(tabState) {
40
- while (tabState.downloads.length > MAX_DOWNLOAD_RECORDS_PER_TAB) {
41
- const stale = tabState.downloads.shift();
42
- await removeDownloadFileIfPresent(stale);
43
- }
44
- }
45
-
46
- async function clearTabDownloads(tabState) {
47
- const entries = Array.isArray(tabState.downloads) ? [...tabState.downloads] : [];
48
- tabState.downloads = [];
49
- await Promise.all(entries.map(removeDownloadFileIfPresent));
50
- }
51
-
52
- async function clearSessionDownloads(session) {
53
- if (!session || !session.tabGroups) return;
54
- const tasks = [];
55
- for (const group of session.tabGroups.values()) {
56
- for (const tabState of group.values()) {
57
- tasks.push(clearTabDownloads(tabState));
58
- }
59
- }
60
- await Promise.all(tasks);
61
- }
62
-
63
- function attachDownloadListener(tabState, tabId, log, pluginEvents, userId) {
64
- if (tabState.downloadListenerAttached) return;
65
- tabState.downloadListenerAttached = true;
66
-
67
- tabState.page.on('download', async (download) => {
68
- const downloadId = crypto.randomUUID();
69
- const suggestedFilename = sanitizeFilename(download.suggestedFilename?.() || `download-${downloadId}.bin`);
70
- const filePath = path.join(os.tmpdir(), `camofox-download-${downloadId}-${suggestedFilename}`);
71
-
72
- const url = String(download.url?.() || '').trim();
73
- if (pluginEvents) {
74
- pluginEvents.emit('tab:download:start', { userId: userId || null, tabId, filename: suggestedFilename, url });
75
- }
76
-
77
- let failure = null;
78
- let bytes = null;
79
-
80
- try {
81
- await download.saveAs(filePath);
82
- const stat = await fs.stat(filePath);
83
- bytes = stat.size;
84
- } catch (err) {
85
- failure = String(err?.message || err || 'download_save_failed');
86
- await fs.unlink(filePath).catch(() => {});
87
- }
88
-
89
- const reportedFailure = await download.failure().catch(() => null);
90
- if (reportedFailure) {
91
- failure = reportedFailure;
92
- }
93
-
94
- if (url) {
95
- tabState.visitedUrls.add(url);
96
- }
97
-
98
- const mimeType = guessMimeTypeFromName(suggestedFilename) || guessMimeTypeFromName(url);
99
- tabState.downloads.push({
100
- id: downloadId,
101
- tabId,
102
- url,
103
- suggestedFilename,
104
- mimeType,
105
- bytes,
106
- createdAt: new Date().toISOString(),
107
- filePath: failure ? null : filePath,
108
- failure,
109
- });
110
-
111
- if (pluginEvents && !failure) {
112
- pluginEvents.emit('tab:download:complete', { userId: userId || null, tabId, filename: suggestedFilename, path: filePath, size: bytes });
113
- }
114
-
115
- await trimTabDownloads(tabState);
116
- log('info', 'download captured', {
117
- tabId, downloadId, suggestedFilename, mimeType, bytes,
118
- hasUrl: Boolean(url), failure,
119
- });
120
- });
121
- }
122
-
123
- /**
124
- * Build the response array for GET /tabs/:tabId/downloads.
125
- */
126
- async function getDownloadsList(tabState, { includeData = false, maxBytes = MAX_DOWNLOAD_INLINE_BYTES } = {}) {
127
- const snapshot = Array.isArray(tabState.downloads) ? [...tabState.downloads] : [];
128
- const downloads = [];
129
-
130
- for (const entry of snapshot) {
131
- const item = {
132
- id: entry.id,
133
- url: entry.url,
134
- suggestedFilename: entry.suggestedFilename,
135
- mimeType: entry.mimeType,
136
- bytes: entry.bytes,
137
- createdAt: entry.createdAt,
138
- failure: entry.failure,
139
- };
140
-
141
- if (includeData && entry.filePath && !entry.failure) {
142
- if (typeof entry.bytes === 'number' && entry.bytes > maxBytes) {
143
- item.dataSkipped = 'max_bytes_exceeded';
144
- } else {
145
- try {
146
- const raw = await fs.readFile(entry.filePath);
147
- item.dataBase64 = raw.toString('base64');
148
- } catch (err) {
149
- item.readError = String(err?.message || err || 'download_read_failed');
150
- }
151
- }
152
- }
153
-
154
- downloads.push(item);
155
- }
156
-
157
- return downloads;
158
- }
159
-
160
- export {
161
- MAX_DOWNLOAD_INLINE_BYTES,
162
- sanitizeFilename,
163
- guessMimeTypeFromName,
164
- clearTabDownloads,
165
- clearSessionDownloads,
166
- attachDownloadListener,
167
- getDownloadsList,
168
- };
1
+ /**
2
+ * Download capture and DOM image extraction for camofox-browser.
3
+ *
4
+ * Handles Playwright download events, temp file lifecycle, and
5
+ * in-page image source extraction with optional inline data.
6
+ */
7
+
8
+ import crypto from 'crypto';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import fs from 'node:fs/promises';
12
+
13
+ const MAX_DOWNLOAD_RECORDS_PER_TAB = 20;
14
+ const MAX_DOWNLOAD_INLINE_BYTES = 20 * 1024 * 1024;
15
+
16
+ function sanitizeFilename(value) {
17
+ return String(value || 'download.bin')
18
+ .replace(/[\\/:*?"<>|\u0000-\u001F]/g, '_')
19
+ .trim()
20
+ .slice(0, 200) || 'download.bin';
21
+ }
22
+
23
+ function guessMimeTypeFromName(value) {
24
+ const normalized = String(value || '').toLowerCase();
25
+ if (normalized.endsWith('.png')) return 'image/png';
26
+ if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
27
+ if (normalized.endsWith('.webp')) return 'image/webp';
28
+ if (normalized.endsWith('.gif')) return 'image/gif';
29
+ if (normalized.endsWith('.svg')) return 'image/svg+xml';
30
+ return 'application/octet-stream';
31
+ }
32
+
33
+ async function removeDownloadFileIfPresent(record) {
34
+ const filePath = record?.filePath;
35
+ if (!filePath) return;
36
+ await fs.unlink(filePath).catch(() => {});
37
+ }
38
+
39
+ async function trimTabDownloads(tabState) {
40
+ while (tabState.downloads.length > MAX_DOWNLOAD_RECORDS_PER_TAB) {
41
+ const stale = tabState.downloads.shift();
42
+ await removeDownloadFileIfPresent(stale);
43
+ }
44
+ }
45
+
46
+ async function clearTabDownloads(tabState) {
47
+ const entries = Array.isArray(tabState.downloads) ? [...tabState.downloads] : [];
48
+ tabState.downloads = [];
49
+ await Promise.all(entries.map(removeDownloadFileIfPresent));
50
+ }
51
+
52
+ async function clearSessionDownloads(session) {
53
+ if (!session || !session.tabGroups) return;
54
+ const tasks = [];
55
+ for (const group of session.tabGroups.values()) {
56
+ for (const tabState of group.values()) {
57
+ tasks.push(clearTabDownloads(tabState));
58
+ }
59
+ }
60
+ await Promise.all(tasks);
61
+ }
62
+
63
+ function attachDownloadListener(tabState, tabId, log, pluginEvents, userId) {
64
+ if (tabState.downloadListenerAttached) return;
65
+ tabState.downloadListenerAttached = true;
66
+
67
+ tabState.page.on('download', async (download) => {
68
+ const downloadId = crypto.randomUUID();
69
+ const suggestedFilename = sanitizeFilename(download.suggestedFilename?.() || `download-${downloadId}.bin`);
70
+ const filePath = path.join(os.tmpdir(), `camofox-download-${downloadId}-${suggestedFilename}`);
71
+
72
+ const url = String(download.url?.() || '').trim();
73
+ if (pluginEvents) {
74
+ pluginEvents.emit('tab:download:start', { userId: userId || null, tabId, filename: suggestedFilename, url });
75
+ }
76
+
77
+ let failure = null;
78
+ let bytes = null;
79
+
80
+ try {
81
+ await download.saveAs(filePath);
82
+ const stat = await fs.stat(filePath);
83
+ bytes = stat.size;
84
+ } catch (err) {
85
+ failure = String(err?.message || err || 'download_save_failed');
86
+ await fs.unlink(filePath).catch(() => {});
87
+ }
88
+
89
+ const reportedFailure = await download.failure().catch(() => null);
90
+ if (reportedFailure) {
91
+ failure = reportedFailure;
92
+ }
93
+
94
+ if (url) {
95
+ tabState.visitedUrls.add(url);
96
+ }
97
+
98
+ const mimeType = guessMimeTypeFromName(suggestedFilename) || guessMimeTypeFromName(url);
99
+ tabState.downloads.push({
100
+ id: downloadId,
101
+ tabId,
102
+ url,
103
+ suggestedFilename,
104
+ mimeType,
105
+ bytes,
106
+ createdAt: new Date().toISOString(),
107
+ filePath: failure ? null : filePath,
108
+ failure,
109
+ });
110
+
111
+ if (pluginEvents && !failure) {
112
+ pluginEvents.emit('tab:download:complete', { userId: userId || null, tabId, filename: suggestedFilename, path: filePath, size: bytes });
113
+ }
114
+
115
+ await trimTabDownloads(tabState);
116
+ log('info', 'download captured', {
117
+ tabId, downloadId, suggestedFilename, mimeType, bytes,
118
+ hasUrl: Boolean(url), failure,
119
+ });
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Build the response array for GET /tabs/:tabId/downloads.
125
+ */
126
+ async function getDownloadsList(tabState, { includeData = false, maxBytes = MAX_DOWNLOAD_INLINE_BYTES } = {}) {
127
+ const snapshot = Array.isArray(tabState.downloads) ? [...tabState.downloads] : [];
128
+ const downloads = [];
129
+
130
+ for (const entry of snapshot) {
131
+ const item = {
132
+ id: entry.id,
133
+ url: entry.url,
134
+ suggestedFilename: entry.suggestedFilename,
135
+ mimeType: entry.mimeType,
136
+ bytes: entry.bytes,
137
+ createdAt: entry.createdAt,
138
+ failure: entry.failure,
139
+ };
140
+
141
+ if (includeData && entry.filePath && !entry.failure) {
142
+ if (typeof entry.bytes === 'number' && entry.bytes > maxBytes) {
143
+ item.dataSkipped = 'max_bytes_exceeded';
144
+ } else {
145
+ try {
146
+ const raw = await fs.readFile(entry.filePath);
147
+ item.dataBase64 = raw.toString('base64');
148
+ } catch (err) {
149
+ item.readError = String(err?.message || err || 'download_read_failed');
150
+ }
151
+ }
152
+ }
153
+
154
+ downloads.push(item);
155
+ }
156
+
157
+ return downloads;
158
+ }
159
+
160
+ export {
161
+ MAX_DOWNLOAD_INLINE_BYTES,
162
+ sanitizeFilename,
163
+ guessMimeTypeFromName,
164
+ clearTabDownloads,
165
+ clearSessionDownloads,
166
+ attachDownloadListener,
167
+ getDownloadsList,
168
+ };
package/lib/extract.js CHANGED
@@ -1,74 +1,74 @@
1
- const SUPPORTED_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'null']);
2
-
3
- export function validateSchema(schema) {
4
- if (!schema || typeof schema !== 'object') {
5
- return { ok: false, error: 'schema must be an object' };
6
- }
7
- if (schema.type !== 'object') {
8
- return { ok: false, error: 'top-level schema must have type: object' };
9
- }
10
- if (!schema.properties || typeof schema.properties !== 'object') {
11
- return { ok: false, error: 'schema must have a properties object' };
12
- }
13
- for (const [prop, def] of Object.entries(schema.properties)) {
14
- if (!def || typeof def !== 'object') {
15
- return { ok: false, error: `property "${prop}" must be an object` };
16
- }
17
- if (def.type && !SUPPORTED_TYPES.has(def.type)) {
18
- return { ok: false, error: `property "${prop}" has unsupported type "${def.type}"` };
19
- }
20
- }
21
- return { ok: true };
22
- }
23
-
24
- function coerceValue(raw, type) {
25
- if (raw == null) return null;
26
- if (type === 'string' || !type) return String(raw).trim();
27
- if (type === 'number') {
28
- const n = parseFloat(String(raw).replace(/[^0-9.eE+-]/g, ''));
29
- return Number.isFinite(n) ? n : null;
30
- }
31
- if (type === 'integer') {
32
- const n = parseInt(String(raw).replace(/[^0-9-]/g, ''), 10);
33
- return Number.isFinite(n) ? n : null;
34
- }
35
- if (type === 'boolean') {
36
- const s = String(raw).toLowerCase().trim();
37
- if (s === 'true' || s === 'yes' || s === '1') return true;
38
- if (s === 'false' || s === 'no' || s === '0') return false;
39
- return null;
40
- }
41
- return raw;
42
- }
43
-
44
- function extractFromRef(refs, refId) {
45
- const info = refs.get(refId);
46
- if (!info) return null;
47
- return info.name || null;
48
- }
49
-
50
- export function extractDeterministic({ schema, refs }) {
51
- const check = validateSchema(schema);
52
- if (!check.ok) throw new Error(check.error);
53
-
54
- const result = {};
55
- for (const [prop, def] of Object.entries(schema.properties)) {
56
- const refId = def['x-ref'];
57
-
58
- let value = null;
59
- if (refId) {
60
- value = extractFromRef(refs, refId);
61
- if (value != null && def.type && def.type !== 'object') {
62
- value = coerceValue(value, def.type);
63
- }
64
- }
65
-
66
- if (value == null && Array.isArray(schema.required) && schema.required.includes(prop)) {
67
- throw new Error(`required property "${prop}" could not be extracted (x-ref=${refId || 'n/a'})`);
68
- }
69
-
70
- result[prop] = value;
71
- }
72
-
73
- return result;
74
- }
1
+ const SUPPORTED_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'null']);
2
+
3
+ export function validateSchema(schema) {
4
+ if (!schema || typeof schema !== 'object') {
5
+ return { ok: false, error: 'schema must be an object' };
6
+ }
7
+ if (schema.type !== 'object') {
8
+ return { ok: false, error: 'top-level schema must have type: object' };
9
+ }
10
+ if (!schema.properties || typeof schema.properties !== 'object') {
11
+ return { ok: false, error: 'schema must have a properties object' };
12
+ }
13
+ for (const [prop, def] of Object.entries(schema.properties)) {
14
+ if (!def || typeof def !== 'object') {
15
+ return { ok: false, error: `property "${prop}" must be an object` };
16
+ }
17
+ if (def.type && !SUPPORTED_TYPES.has(def.type)) {
18
+ return { ok: false, error: `property "${prop}" has unsupported type "${def.type}"` };
19
+ }
20
+ }
21
+ return { ok: true };
22
+ }
23
+
24
+ function coerceValue(raw, type) {
25
+ if (raw == null) return null;
26
+ if (type === 'string' || !type) return String(raw).trim();
27
+ if (type === 'number') {
28
+ const n = parseFloat(String(raw).replace(/[^0-9.eE+-]/g, ''));
29
+ return Number.isFinite(n) ? n : null;
30
+ }
31
+ if (type === 'integer') {
32
+ const n = parseInt(String(raw).replace(/[^0-9-]/g, ''), 10);
33
+ return Number.isFinite(n) ? n : null;
34
+ }
35
+ if (type === 'boolean') {
36
+ const s = String(raw).toLowerCase().trim();
37
+ if (s === 'true' || s === 'yes' || s === '1') return true;
38
+ if (s === 'false' || s === 'no' || s === '0') return false;
39
+ return null;
40
+ }
41
+ return raw;
42
+ }
43
+
44
+ function extractFromRef(refs, refId) {
45
+ const info = refs.get(refId);
46
+ if (!info) return null;
47
+ return info.name || null;
48
+ }
49
+
50
+ export function extractDeterministic({ schema, refs }) {
51
+ const check = validateSchema(schema);
52
+ if (!check.ok) throw new Error(check.error);
53
+
54
+ const result = {};
55
+ for (const [prop, def] of Object.entries(schema.properties)) {
56
+ const refId = def['x-ref'];
57
+
58
+ let value = null;
59
+ if (refId) {
60
+ value = extractFromRef(refs, refId);
61
+ if (value != null && def.type && def.type !== 'object') {
62
+ value = coerceValue(value, def.type);
63
+ }
64
+ }
65
+
66
+ if (value == null && Array.isArray(schema.required) && schema.required.includes(prop)) {
67
+ throw new Error(`required property "${prop}" could not be extracted (x-ref=${refId || 'n/a'})`);
68
+ }
69
+
70
+ result[prop] = value;
71
+ }
72
+
73
+ return result;
74
+ }
package/lib/fly.js CHANGED
@@ -1,54 +1,54 @@
1
- /**
2
- * Fly.io horizontal scaling helpers.
3
- *
4
- * Tab IDs encode the owning machine: "{machineId}_{uuid}"
5
- * Requests for tabs on other machines get replayed via fly-replay header.
6
- *
7
- * When not running on Fly (no FLY_MACHINE_ID), all helpers are no-ops:
8
- * makeTabId() returns a plain UUID and isLocalTab() always returns true.
9
- */
10
-
11
- import crypto from 'crypto';
12
-
13
- export function createFlyHelpers(config) {
14
- const machineId = config.flyMachineId || '';
15
-
16
- function makeTabId() {
17
- const uuid = crypto.randomUUID();
18
- return machineId ? `${machineId}_${uuid}` : uuid;
19
- }
20
-
21
- function parseTabOwner(tabId) {
22
- if (!machineId || !tabId) return null;
23
- const idx = tabId.indexOf('_');
24
- if (idx === -1) return null; // legacy tab ID (no machine prefix)
25
- const candidate = tabId.slice(0, idx);
26
- // Fly machine IDs are hex strings (14 chars). UUIDs start with 8 hex chars then '-'.
27
- // If the candidate contains '-', it's a UUID segment, not a machine ID.
28
- if (candidate.includes('-')) return null;
29
- return candidate;
30
- }
31
-
32
- function isLocalTab(tabId) {
33
- const owner = parseTabOwner(tabId);
34
- return owner === null || owner === machineId;
35
- }
36
-
37
- /**
38
- * Express middleware: replay requests for tabs owned by other machines.
39
- * No-op when not running on Fly.
40
- */
41
- function replayMiddleware(log) {
42
- return (req, res, next) => {
43
- if (!machineId) return next();
44
- const tabId = req.params.tabId;
45
- if (!tabId || isLocalTab(tabId)) return next();
46
- const owner = parseTabOwner(tabId);
47
- log('info', 'fly-replay', { reqId: req.reqId, tabId, owner, self: machineId });
48
- res.set('fly-replay', `instance=${owner}`);
49
- res.status(307).send();
50
- };
51
- }
52
-
53
- return { machineId, makeTabId, parseTabOwner, isLocalTab, replayMiddleware };
54
- }
1
+ /**
2
+ * Fly.io horizontal scaling helpers.
3
+ *
4
+ * Tab IDs encode the owning machine: "{machineId}_{uuid}"
5
+ * Requests for tabs on other machines get replayed via fly-replay header.
6
+ *
7
+ * When not running on Fly (no FLY_MACHINE_ID), all helpers are no-ops:
8
+ * makeTabId() returns a plain UUID and isLocalTab() always returns true.
9
+ */
10
+
11
+ import crypto from 'crypto';
12
+
13
+ export function createFlyHelpers(config) {
14
+ const machineId = config.flyMachineId || '';
15
+
16
+ function makeTabId() {
17
+ const uuid = crypto.randomUUID();
18
+ return machineId ? `${machineId}_${uuid}` : uuid;
19
+ }
20
+
21
+ function parseTabOwner(tabId) {
22
+ if (!machineId || !tabId) return null;
23
+ const idx = tabId.indexOf('_');
24
+ if (idx === -1) return null; // legacy tab ID (no machine prefix)
25
+ const candidate = tabId.slice(0, idx);
26
+ // Fly machine IDs are hex strings (14 chars). UUIDs start with 8 hex chars then '-'.
27
+ // If the candidate contains '-', it's a UUID segment, not a machine ID.
28
+ if (candidate.includes('-')) return null;
29
+ return candidate;
30
+ }
31
+
32
+ function isLocalTab(tabId) {
33
+ const owner = parseTabOwner(tabId);
34
+ return owner === null || owner === machineId;
35
+ }
36
+
37
+ /**
38
+ * Express middleware: replay requests for tabs owned by other machines.
39
+ * No-op when not running on Fly.
40
+ */
41
+ function replayMiddleware(log) {
42
+ return (req, res, next) => {
43
+ if (!machineId) return next();
44
+ const tabId = req.params.tabId;
45
+ if (!tabId || isLocalTab(tabId)) return next();
46
+ const owner = parseTabOwner(tabId);
47
+ log('info', 'fly-replay', { reqId: req.reqId, tabId, owner, self: machineId });
48
+ res.set('fly-replay', `instance=${owner}`);
49
+ res.status(307).send();
50
+ };
51
+ }
52
+
53
+ return { machineId, makeTabId, parseTabOwner, isLocalTab, replayMiddleware };
54
+ }