@page-scanner/cli 0.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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +217 -0
  3. package/dist/api.d.ts +87 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +132 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +49 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/bridge/server.d.ts +97 -0
  12. package/dist/bridge/server.d.ts.map +1 -0
  13. package/dist/bridge/server.js +329 -0
  14. package/dist/bridge/server.js.map +1 -0
  15. package/dist/bridge/tokens.d.ts +10 -0
  16. package/dist/bridge/tokens.d.ts.map +1 -0
  17. package/dist/bridge/tokens.js +15 -0
  18. package/dist/bridge/tokens.js.map +1 -0
  19. package/dist/cli/args.d.ts +63 -0
  20. package/dist/cli/args.d.ts.map +1 -0
  21. package/dist/cli/args.js +388 -0
  22. package/dist/cli/args.js.map +1 -0
  23. package/dist/cli/commands.d.ts +8 -0
  24. package/dist/cli/commands.d.ts.map +1 -0
  25. package/dist/cli/commands.js +234 -0
  26. package/dist/cli/commands.js.map +1 -0
  27. package/dist/cli/format.d.ts +22 -0
  28. package/dist/cli/format.d.ts.map +1 -0
  29. package/dist/cli/format.js +152 -0
  30. package/dist/cli/format.js.map +1 -0
  31. package/dist/config.d.ts +17 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +54 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/daemon/client.d.ts +28 -0
  36. package/dist/daemon/client.d.ts.map +1 -0
  37. package/dist/daemon/client.js +139 -0
  38. package/dist/daemon/client.js.map +1 -0
  39. package/dist/daemon/rpc-server.d.ts +31 -0
  40. package/dist/daemon/rpc-server.d.ts.map +1 -0
  41. package/dist/daemon/rpc-server.js +254 -0
  42. package/dist/daemon/rpc-server.js.map +1 -0
  43. package/dist/daemon/run.d.ts +20 -0
  44. package/dist/daemon/run.d.ts.map +1 -0
  45. package/dist/daemon/run.js +129 -0
  46. package/dist/daemon/run.js.map +1 -0
  47. package/dist/daemon/self.d.ts +25 -0
  48. package/dist/daemon/self.d.ts.map +1 -0
  49. package/dist/daemon/self.js +113 -0
  50. package/dist/daemon/self.js.map +1 -0
  51. package/dist/daemon/state.d.ts +25 -0
  52. package/dist/daemon/state.d.ts.map +1 -0
  53. package/dist/daemon/state.js +116 -0
  54. package/dist/daemon/state.js.map +1 -0
  55. package/dist/errors.d.ts +32 -0
  56. package/dist/errors.d.ts.map +1 -0
  57. package/dist/errors.js +83 -0
  58. package/dist/errors.js.map +1 -0
  59. package/dist/index.d.ts +17 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +16 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +71 -0
  66. package/dist/output.js.map +1 -0
  67. package/dist/protocol.d.ts +144 -0
  68. package/dist/protocol.d.ts.map +1 -0
  69. package/dist/protocol.js +128 -0
  70. package/dist/protocol.js.map +1 -0
  71. package/dist/version.d.ts +11 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +11 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +77 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The daemon's control surface: a loopback HTTP endpoint, `POST /rpc`.
3
+ *
4
+ * Why a second port rather than reusing 45711: that one has to keep refusing
5
+ * everything whose `Origin` is not a `chrome-extension://`, which is exactly
6
+ * what a local CLI process is. Giving the two audiences two doors is cheaper
7
+ * than weakening the check on the one the browser uses.
8
+ *
9
+ * The port is ephemeral and the bearer secret is regenerated every run, both
10
+ * recorded in `daemon.json` at mode 0600. A loopback port is reachable by
11
+ * every process on the machine, so the secret is not decoration: without it,
12
+ * anything running as the user could drive the browser.
13
+ *
14
+ * No dependencies. `node:http` is enough for one route.
15
+ */
16
+ import { createServer } from 'node:http';
17
+ import { toPageScannerError } from '../errors.js';
18
+ import { PageScannerError } from '../errors.js';
19
+ import { DEFAULT_PAGE_SIZE, EXPORT_FORMAT_IDS, PAGE_SIZE_IDS, VIDEO_HANDLINGS, } from '../protocol.js';
20
+ import { CLI_VERSION } from '../version.js';
21
+ import { tokensMatch } from '../bridge/tokens.js';
22
+ /** A body larger than this is not a request we would understand anyway. */
23
+ const MAX_BODY_BYTES = 1_000_000;
24
+ function readBody(request) {
25
+ return new Promise((resolve, reject) => {
26
+ let size = 0;
27
+ const chunks = [];
28
+ request.on('data', (chunk) => {
29
+ size += chunk.length;
30
+ if (size > MAX_BODY_BYTES) {
31
+ reject(new PageScannerError('BAD_REQUEST', 'Request body too large.'));
32
+ request.destroy();
33
+ return;
34
+ }
35
+ chunks.push(chunk);
36
+ });
37
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
38
+ request.on('error', reject);
39
+ });
40
+ }
41
+ function send(response, status, payload) {
42
+ const body = JSON.stringify(payload);
43
+ response.writeHead(status, {
44
+ 'content-type': 'application/json; charset=utf-8',
45
+ 'content-length': Buffer.byteLength(body),
46
+ });
47
+ response.end(body);
48
+ }
49
+ function asRecord(value) {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
51
+ ? value
52
+ : {};
53
+ }
54
+ function optionalString(params, key) {
55
+ const value = params[key];
56
+ if (value === undefined)
57
+ return undefined;
58
+ if (typeof value !== 'string' || value.length === 0) {
59
+ throw new PageScannerError('BAD_REQUEST', `${key} must be a non-empty string.`);
60
+ }
61
+ return value;
62
+ }
63
+ function optionalInteger(params, key) {
64
+ const value = params[key];
65
+ if (value === undefined)
66
+ return undefined;
67
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
68
+ throw new PageScannerError('BAD_REQUEST', `${key} must be an integer.`);
69
+ }
70
+ return value;
71
+ }
72
+ function timeoutFrom(params, fallback) {
73
+ const value = params.timeoutMs;
74
+ if (value === undefined)
75
+ return fallback;
76
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
77
+ throw new PageScannerError('BAD_REQUEST', 'timeoutMs must be a non-negative number.');
78
+ }
79
+ return value;
80
+ }
81
+ function oneOf(params, key, allowed, fallback) {
82
+ const value = params[key];
83
+ if (value === undefined)
84
+ return fallback;
85
+ if (typeof value !== 'string' || !allowed.includes(value)) {
86
+ throw new PageScannerError('BAD_REQUEST', `${key} must be one of ${allowed.join(', ')} (got ${JSON.stringify(value)}).`);
87
+ }
88
+ return value;
89
+ }
90
+ /**
91
+ * The daemon's methods.
92
+ *
93
+ * `scan` deliberately returns the bytes rather than a path. The daemon may
94
+ * have been started from a different directory, hours ago; only the calling
95
+ * process knows what a relative `--out` means, so it does the writing.
96
+ */
97
+ async function dispatch(method, params, options) {
98
+ const { bridge } = options;
99
+ switch (method) {
100
+ case 'health':
101
+ return {
102
+ ok: true,
103
+ version: CLI_VERSION,
104
+ pid: process.pid,
105
+ bridgePort: bridge.port,
106
+ browsers: bridge.listBrowsers().length,
107
+ };
108
+ case 'listBrowsers':
109
+ return { browsers: bridge.listBrowsers() };
110
+ case 'waitForBrowser': {
111
+ const browserId = await bridge.waitForBrowser({
112
+ timeoutMs: timeoutFrom(params, 30_000),
113
+ browserId: optionalString(params, 'browserId'),
114
+ });
115
+ const found = bridge.listBrowsers().find((b) => b.browserId === browserId);
116
+ return { browserId, label: found?.label ?? '' };
117
+ }
118
+ case 'listTabs': {
119
+ const browserId = optionalString(params, 'browserId');
120
+ const waitMs = timeoutFrom(params, 0);
121
+ const resolved = waitMs > 0
122
+ ? await bridge.waitForBrowser({ timeoutMs: waitMs, browserId })
123
+ : bridge.resolveBrowser(browserId);
124
+ const answer = await bridge.listTabs(resolved);
125
+ const found = bridge.listBrowsers().find((b) => b.browserId === resolved);
126
+ return {
127
+ browserId: resolved,
128
+ label: found?.label ?? '',
129
+ windows: answer.windows,
130
+ tabs: answer.tabs,
131
+ };
132
+ }
133
+ case 'scan': {
134
+ const browserId = optionalString(params, 'browserId');
135
+ const waitMs = timeoutFrom(params, 0);
136
+ const resolved = waitMs > 0
137
+ ? await bridge.waitForBrowser({ timeoutMs: waitMs, browserId })
138
+ : bridge.resolveBrowser(browserId);
139
+ const url = optionalString(params, 'url');
140
+ const tabId = optionalInteger(params, 'tabId');
141
+ if ((url === undefined) === (tabId === undefined)) {
142
+ throw new PageScannerError('BAD_REQUEST', 'Give exactly one of url and tabId.');
143
+ }
144
+ const quality = params.quality;
145
+ if (quality !== undefined && (typeof quality !== 'number' || !(quality > 0) || quality > 1)) {
146
+ throw new PageScannerError('BAD_REQUEST', 'quality must be a number in (0, 1].');
147
+ }
148
+ const answer = await bridge.scan(resolved, {
149
+ ...(url !== undefined ? { url } : {}),
150
+ ...(tabId !== undefined ? { tabId } : {}),
151
+ ...(optionalInteger(params, 'windowId') !== undefined
152
+ ? { windowId: optionalInteger(params, 'windowId') }
153
+ : {}),
154
+ format: oneOf(params, 'format', EXPORT_FORMAT_IDS, 'pdf'),
155
+ pageSize: oneOf(params, 'pageSize', PAGE_SIZE_IDS, DEFAULT_PAGE_SIZE),
156
+ ...(quality !== undefined ? { quality } : {}),
157
+ ...(params.videoHandling !== undefined
158
+ ? {
159
+ videoHandling: oneOf(params, 'videoHandling', VIDEO_HANDLINGS, 'frame'),
160
+ }
161
+ : {}),
162
+ ...(params.openEditor !== undefined ? { openEditor: params.openEditor === true } : {}),
163
+ }, timeoutFrom({ timeoutMs: params.scanTimeoutMs }, 120_000));
164
+ return {
165
+ browserId: resolved,
166
+ width: answer.width,
167
+ height: answer.height,
168
+ mode: answer.mode,
169
+ fileName: answer.fileName,
170
+ truncated: answer.truncated ?? null,
171
+ bytesBase64: answer.bytesBase64,
172
+ };
173
+ }
174
+ case 'shutdown':
175
+ return { ok: true };
176
+ default:
177
+ throw new PageScannerError('BAD_REQUEST', `Unknown method ${JSON.stringify(method)}.`);
178
+ }
179
+ }
180
+ export function startRpcServer(options) {
181
+ const server = createServer((request, response) => {
182
+ void handle(request, response, options, server);
183
+ });
184
+ // A scan can sit on a long-poll for two minutes. Node's default request
185
+ // timeout would cut it off at five, and `--wait` plus `--timeout` can
186
+ // legitimately exceed that; the client sets its own deadline instead.
187
+ server.requestTimeout = 0;
188
+ server.headersTimeout = 0;
189
+ return new Promise((resolve, reject) => {
190
+ const onError = (error) => reject(error);
191
+ server.once('error', onError);
192
+ server.listen(options.port ?? 0, '127.0.0.1', () => {
193
+ server.off('error', onError);
194
+ const address = server.address();
195
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
196
+ resolve({
197
+ port,
198
+ close: () => new Promise((done) => {
199
+ server.closeAllConnections();
200
+ server.close(() => done());
201
+ }),
202
+ });
203
+ });
204
+ });
205
+ }
206
+ async function handle(request, response, options, server) {
207
+ const unauthorised = {
208
+ ok: false,
209
+ error: { code: 'BAD_REQUEST', message: 'Unauthorized.' },
210
+ };
211
+ const header = request.headers.authorization ?? '';
212
+ const presented = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
213
+ if (!tokensMatch(presented, options.secret)) {
214
+ send(response, 401, unauthorised);
215
+ return;
216
+ }
217
+ if (request.method !== 'POST' || (request.url ?? '') !== '/rpc') {
218
+ send(response, 404, {
219
+ ok: false,
220
+ error: { code: 'BAD_REQUEST', message: 'POST /rpc is the only route.' },
221
+ });
222
+ return;
223
+ }
224
+ options.onActivity?.();
225
+ let method = '';
226
+ try {
227
+ const raw = await readBody(request);
228
+ const body = asRecord(JSON.parse(raw || '{}'));
229
+ if (typeof body.method !== 'string' || body.method.length === 0) {
230
+ throw new PageScannerError('BAD_REQUEST', 'method is required.');
231
+ }
232
+ method = body.method;
233
+ const result = await dispatch(method, asRecord(body.params), options);
234
+ send(response, 200, { ok: true, result });
235
+ }
236
+ catch (error) {
237
+ const failure = toPageScannerError(error);
238
+ send(response, 200, {
239
+ ok: false,
240
+ error: {
241
+ code: failure.code,
242
+ message: failure.message,
243
+ ...(failure.hint !== undefined ? { hint: failure.hint } : {}),
244
+ },
245
+ });
246
+ }
247
+ // Answer first, then go: the caller should see `{ ok: true }` rather than a
248
+ // dropped connection.
249
+ if (method === 'shutdown') {
250
+ server.closeIdleConnections();
251
+ options.onShutdown();
252
+ }
253
+ }
254
+ //# sourceMappingURL=rpc-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc-server.js","sourceRoot":"","sources":["../../src/daemon/rpc-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,YAAY,EAA0D,MAAM,WAAW,CAAC;AAEjG,OAAO,EAAE,kBAAkB,EAAkB,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,eAAe,GAChB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,2EAA2E;AAC3E,MAAM,cAAc,GAAG,SAAS,CAAC;AA8BjC,SAAS,QAAQ,CAAC,OAAwB;IACxC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACnC,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC;YACrB,IAAI,IAAI,GAAG,cAAc,EAAE,CAAC;gBAC1B,MAAM,CAAC,IAAI,gBAAgB,CAAC,aAAa,EAAE,yBAAyB,CAAC,CAAC,CAAC;gBACvE,OAAO,CAAC,OAAO,EAAE,CAAC;gBAClB,OAAO;YACT,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACrB,CAAC,CAAC,CAAC;QACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACzE,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,IAAI,CAAC,QAAwB,EAAE,MAAc,EAAE,OAAoB;IAC1E,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IACrC,QAAQ,CAAC,SAAS,CAAC,MAAM,EAAE;QACzB,cAAc,EAAE,iCAAiC;QACjD,gBAAgB,EAAE,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC;KAC1C,CAAC,CAAC;IACH,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACrB,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QACzE,CAAC,CAAE,KAAiC;QACpC,CAAC,CAAC,EAAE,CAAC;AACT,CAAC;AAED,SAAS,cAAc,CAAC,MAA+B,EAAE,GAAW;IAClE,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC1C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpD,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,GAAG,GAAG,8BAA8B,CAAC,CAAC;IAClF,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,eAAe,CAAC,MAA+B,EAAE,GAAW;IACnE,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC1C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,GAAG,GAAG,sBAAsB,CAAC,CAAC;IAC1E,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,WAAW,CAAC,MAA+B,EAAE,QAAgB;IACpE,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC;IAC/B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACtE,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,0CAA0C,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,KAAK,CACZ,MAA+B,EAC/B,GAAW,EACX,OAAqB,EACrB,QAAW;IAEX,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAU,CAAC,EAAE,CAAC;QAC/D,MAAM,IAAI,gBAAgB,CACxB,aAAa,EACb,GAAG,GAAG,mBAAmB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAC9E,CAAC;IACJ,CAAC;IACD,OAAO,KAAU,CAAC;AACpB,CAAC;AAED;;;;;;GAMG;AACH,KAAK,UAAU,QAAQ,CACrB,MAAc,EACd,MAA+B,EAC/B,OAAyB;IAEzB,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAE3B,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,QAAQ;YACX,OAAO;gBACL,EAAE,EAAE,IAAI;gBACR,OAAO,EAAE,WAAW;gBACpB,GAAG,EAAE,OAAO,CAAC,GAAG;gBAChB,UAAU,EAAE,MAAM,CAAC,IAAI;gBACvB,QAAQ,EAAE,MAAM,CAAC,YAAY,EAAE,CAAC,MAAM;aACvC,CAAC;QAEJ,KAAK,cAAc;YACjB,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,YAAY,EAAE,EAAE,CAAC;QAE7C,KAAK,gBAAgB,CAAC,CAAC,CAAC;YACtB,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC;gBAC5C,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC;gBACtC,SAAS,EAAE,cAAc,CAAC,MAAM,EAAE,WAAW,CAAC;aAC/C,CAAC,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,CAAC,YAAY,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;YAC3E,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE,EAAE,CAAC;QAClD,CAAC;QAED,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,MAAM,SAAS,GAAG,cAAc,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;YACtD,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;YACtC,MAAM,QAAQ,GACZ,MAAM,GAAG,CAAC;gBACR,CAAC,CAAC,MAAM,MAAM,CAAC,cAAc,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;gBAC/D,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC;YACvC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAC/C,MAAM,KAAK,GAAG,MAAM,CAAC,YAAY,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC;YAC1E,OAAO;gBACL,SAAS,EAAE,QAAQ;gBACnB,KAAK,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE;gBACzB,OAAO,EAAE,MAAM,CAAC,OAAO;gBACvB,IAAI,EAAE,MAAM,CAAC,IAAI;aAClB,CAAC;QACJ,CAAC;QAED,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,SAAS,GAAG,cAAc,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;YACtD,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;YACtC,MAAM,QAAQ,GACZ,MAAM,GAAG,CAAC;gBACR,CAAC,CAAC,MAAM,MAAM,CAAC,cAAc,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;gBAC/D,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC;YAEvC,MAAM,GAAG,GAAG,cAAc,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;YAC1C,MAAM,KAAK,GAAG,eAAe,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAC/C,IAAI,CAAC,GAAG,KAAK,SAAS,CAAC,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,EAAE,CAAC;gBAClD,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,oCAAoC,CAAC,CAAC;YAClF,CAAC;YAED,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;YAC/B,IAAI,OAAO,KAAK,SAAS,IAAI,CAAC,OAAO,OAAO,KAAK,QAAQ,IAAI,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC,EAAE,CAAC;gBAC5F,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,qCAAqC,CAAC,CAAC;YACnF,CAAC;YAED,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,IAAI,CAC9B,QAAQ,EACR;gBACE,GAAG,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACrC,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACzC,GAAG,CAAC,eAAe,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,SAAS;oBACnD,CAAC,CAAC,EAAE,QAAQ,EAAE,eAAe,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE;oBACnD,CAAC,CAAC,EAAE,CAAC;gBACP,MAAM,EAAE,KAAK,CAAiB,MAAM,EAAE,QAAQ,EAAE,iBAAiB,EAAE,KAAK,CAAC;gBACzE,QAAQ,EAAE,KAAK,CAAa,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,iBAAiB,CAAC;gBACjF,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC7C,GAAG,CAAC,MAAM,CAAC,aAAa,KAAK,SAAS;oBACpC,CAAC,CAAC;wBACE,aAAa,EAAE,KAAK,CAClB,MAAM,EACN,eAAe,EACf,eAAe,EACf,OAAO,CACR;qBACF;oBACH,CAAC,CAAC,EAAE,CAAC;gBACP,GAAG,CAAC,MAAM,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACvF,EACD,WAAW,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,aAAa,EAAE,EAAE,OAAO,CAAC,CAC1D,CAAC;YAEF,OAAO;gBACL,SAAS,EAAE,QAAQ;gBACnB,KAAK,EAAE,MAAM,CAAC,KAAK;gBACnB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,SAAS,EAAE,MAAM,CAAC,SAAS,IAAI,IAAI;gBACnC,WAAW,EAAE,MAAM,CAAC,WAAW;aAChC,CAAC;QACJ,CAAC;QAED,KAAK,UAAU;YACb,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;QAEtB;YACE,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,kBAAkB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC3F,CAAC;AACH,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,OAAyB;IACtD,MAAM,MAAM,GAAW,YAAY,CAAC,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE;QACxD,KAAK,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,wEAAwE;IACxE,sEAAsE;IACtE,sEAAsE;IACtE,MAAM,CAAC,cAAc,GAAG,CAAC,CAAC;IAC1B,MAAM,CAAC,cAAc,GAAG,CAAC,CAAC;IAE1B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,OAAO,GAAG,CAAC,KAAY,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAChD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC9B,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE;YACjD,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC7B,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;YACjC,MAAM,IAAI,GAAG,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAChF,OAAO,CAAC;gBACN,IAAI;gBACJ,KAAK,EAAE,GAAG,EAAE,CACV,IAAI,OAAO,CAAO,CAAC,IAAI,EAAE,EAAE;oBACzB,MAAM,CAAC,mBAAmB,EAAE,CAAC;oBAC7B,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC7B,CAAC,CAAC;aACL,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,MAAM,CACnB,OAAwB,EACxB,QAAwB,EACxB,OAAyB,EACzB,MAAc;IAEd,MAAM,YAAY,GAAe;QAC/B,EAAE,EAAE,KAAK;QACT,KAAK,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,eAAe,EAAE;KACzD,CAAC;IAEF,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC;IACnD,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACrF,IAAI,CAAC,WAAW,CAAC,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAC5C,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,YAAY,CAAC,CAAC;QAClC,OAAO;IACT,CAAC;IAED,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE;YAClB,EAAE,EAAE,KAAK;YACT,KAAK,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,8BAA8B,EAAE;SACxE,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;IAEvB,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,CAAC;QACpC,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,CAAC,CAAC;QAC/C,IAAI,OAAO,IAAI,CAAC,MAAM,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAChE,MAAM,IAAI,gBAAgB,CAAC,aAAa,EAAE,qBAAqB,CAAC,CAAC;QACnE,CAAC;QACD,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QACrB,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;QACtE,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IAC5C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE;YAClB,EAAE,EAAE,KAAK;YACT,KAAK,EAAE;gBACL,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,OAAO,EAAE,OAAO,CAAC,OAAO;gBACxB,GAAG,CAAC,OAAO,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAC9D;SACF,CAAC,CAAC;IACL,CAAC;IAED,4EAA4E;IAC5E,sBAAsB;IACtB,IAAI,MAAM,KAAK,UAAU,EAAE,CAAC;QAC1B,MAAM,CAAC,oBAAoB,EAAE,CAAC;QAC9B,OAAO,CAAC,UAAU,EAAE,CAAC;IACvB,CAAC;AACH,CAAC"}
@@ -0,0 +1,20 @@
1
+ /** How long the daemon stays up with nothing connected and nobody calling. */
2
+ export declare const DEFAULT_IDLE_MINUTES = 15;
3
+ export interface RunDaemonOptions {
4
+ /** Detached: no interactive output, and the log goes to the file. */
5
+ detached?: boolean;
6
+ /** 0 means never exit on idle. Defaults to PAGE_SCANNER_IDLE_MINUTES or 15. */
7
+ idleMinutes?: number;
8
+ log?: (message: string) => void;
9
+ /** Resolves when the daemon has stopped. Awaited by `serve` in the foreground. */
10
+ onListening?: (info: {
11
+ bridgePort: number;
12
+ rpcPort: number;
13
+ }) => void;
14
+ }
15
+ /**
16
+ * Runs until something stops it: a `shutdown` call, SIGINT, SIGTERM, or the
17
+ * idle timer. Resolves when it has shut down cleanly.
18
+ */
19
+ export declare function runDaemon(options?: RunDaemonOptions): Promise<void>;
20
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/daemon/run.ts"],"names":[],"mappings":"AAeA,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAGvC,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,kFAAkF;IAClF,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACvE;AAUD;;;GAGG;AACH,wBAAsB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyH7E"}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The daemon process: the bridge listener plus its RPC control surface.
3
+ *
4
+ * `page-scanner serve` runs it in the foreground; `serve --daemon` is what
5
+ * `connectDaemon` spawns behind a command that needed one. Both end up here.
6
+ */
7
+ import { randomBytes } from 'node:crypto';
8
+ import { readConfig } from '../config.js';
9
+ import { PageScannerError } from '../errors.js';
10
+ import { BridgeServer } from '../bridge/server.js';
11
+ import { CLI_VERSION } from '../version.js';
12
+ import { startRpcServer } from './rpc-server.js';
13
+ import { clearDaemonState, readDaemonState, writeDaemonState } from './state.js';
14
+ /** How long the daemon stays up with nothing connected and nobody calling. */
15
+ export const DEFAULT_IDLE_MINUTES = 15;
16
+ const IDLE_CHECK_INTERVAL_MS = 30_000;
17
+ function idleMinutesFrom(explicit) {
18
+ if (explicit !== undefined)
19
+ return explicit;
20
+ const fromEnv = process.env.PAGE_SCANNER_IDLE_MINUTES;
21
+ if (fromEnv === undefined || fromEnv === '')
22
+ return DEFAULT_IDLE_MINUTES;
23
+ const parsed = Number(fromEnv);
24
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_IDLE_MINUTES;
25
+ }
26
+ /**
27
+ * Runs until something stops it: a `shutdown` call, SIGINT, SIGTERM, or the
28
+ * idle timer. Resolves when it has shut down cleanly.
29
+ */
30
+ export async function runDaemon(options = {}) {
31
+ const log = options.log ?? ((message) => process.stderr.write(`${message}\n`));
32
+ const existing = readDaemonState();
33
+ if (existing) {
34
+ log(`page-scanner is already running (pid ${existing.pid}, rpc port ${existing.rpcPort}).`);
35
+ return;
36
+ }
37
+ const config = readConfig();
38
+ if (!config.token) {
39
+ // Refusing beats starting unpaired. An empty token would compare equal to
40
+ // the empty token any process could send, so an unpaired daemon is not a
41
+ // daemon with no clients, it is one that accepts everybody.
42
+ throw new PageScannerError('NOT_PAIRED', 'There is no pairing on this machine, so the bridge has nothing to check against.', 'Run `page-scanner pair` first.');
43
+ }
44
+ const bridge = new BridgeServer({ port: config.port, token: config.token, log });
45
+ try {
46
+ await bridge.start();
47
+ }
48
+ catch (error) {
49
+ const code = error.code;
50
+ if (code === 'EADDRINUSE') {
51
+ throw new PageScannerError('PORT_IN_USE', `Port ${config.port} is already in use, and no page-scanner daemon is registered on it.`, 'An older page-scanner-mcp 0.1 holds this port when it is running; stop it, or pick ' +
52
+ 'another port with `page-scanner pair --port <n>` and re-pair the browser.');
53
+ }
54
+ throw new PageScannerError('DAEMON_FAILED', `Could not listen on 127.0.0.1:${config.port}: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ const secret = randomBytes(32).toString('base64url');
57
+ let lastActivity = Date.now();
58
+ let stopping = false;
59
+ // `stop` can be called from four places (a shutdown call, two signals and the
60
+ // idle timer) at any point after the RPC server is up, so the promise it
61
+ // settles has to exist before any of them is wired.
62
+ let finish = () => { };
63
+ const stopped = new Promise((resolve) => {
64
+ finish = resolve;
65
+ });
66
+ // Assigned as each one comes up, so a shutdown arriving between two awaits
67
+ // tears down whatever exists rather than tripping over a binding that is not
68
+ // initialised yet.
69
+ let rpc = null;
70
+ let idleTimer = null;
71
+ const shutdown = async () => {
72
+ if (idleTimer)
73
+ clearInterval(idleTimer);
74
+ process.off('SIGINT', stop);
75
+ process.off('SIGTERM', stop);
76
+ if (rpc)
77
+ await rpc.close();
78
+ await bridge.close();
79
+ clearDaemonState();
80
+ log('page-scanner daemon stopped.');
81
+ finish();
82
+ };
83
+ const stop = () => {
84
+ if (stopping)
85
+ return;
86
+ stopping = true;
87
+ void shutdown();
88
+ };
89
+ rpc = await startRpcServer({
90
+ bridge,
91
+ secret,
92
+ onShutdown: stop,
93
+ onActivity: () => {
94
+ lastActivity = Date.now();
95
+ },
96
+ });
97
+ writeDaemonState({
98
+ pid: process.pid,
99
+ rpcPort: rpc.port,
100
+ secret,
101
+ bridgePort: bridge.port,
102
+ version: CLI_VERSION,
103
+ startedAt: Date.now(),
104
+ });
105
+ options.onListening?.({ bridgePort: bridge.port, rpcPort: rpc.port });
106
+ log(`page-scanner daemon ${CLI_VERSION} ready (pid ${process.pid}, rpc port ${rpc.port}).`);
107
+ const idleMinutes = idleMinutesFrom(options.idleMinutes);
108
+ idleTimer =
109
+ idleMinutes > 0
110
+ ? setInterval(() => {
111
+ // Never exit while a browser is attached. The open socket is what
112
+ // keeps Chrome's service worker alive; dropping it would retire the
113
+ // worker and make the next command wait for a reconnect.
114
+ if (bridge.listBrowsers().length > 0) {
115
+ lastActivity = Date.now();
116
+ return;
117
+ }
118
+ if (Date.now() - lastActivity >= idleMinutes * 60_000) {
119
+ log(`idle for ${idleMinutes} minutes with nothing connected, stopping.`);
120
+ stop();
121
+ }
122
+ }, IDLE_CHECK_INTERVAL_MS)
123
+ : null;
124
+ idleTimer?.unref();
125
+ process.on('SIGINT', stop);
126
+ process.on('SIGTERM', stop);
127
+ await stopped;
128
+ }
129
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/daemon/run.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ export interface SelfCommand {
2
+ /** The executable to spawn. */
3
+ command: string;
4
+ /** Arguments that come before the subcommand. */
5
+ args: string[];
6
+ }
7
+ /** True when running inside a `bun build --compile` single-file executable. */
8
+ export declare function isCompiledBinary(argv?: string[]): boolean;
9
+ /**
10
+ * This package's own `bin`, found from this module's location rather than from
11
+ * the process. Undefined when there is no file there, which means the code has
12
+ * been bundled into one file and the caller should fall back to `argv[1]`.
13
+ */
14
+ export declare function packageEntry(moduleUrl?: string): string | undefined;
15
+ /**
16
+ * How to run this package's CLI again.
17
+ * @param argv defaults to process.argv, injectable for tests.
18
+ * @param execPath defaults to process.execPath, injectable for tests.
19
+ * @param findEntry how to locate this package's bin, injectable for tests. It
20
+ * is a function rather than a value because a default parameter fires on an
21
+ * explicit `undefined` too, so a test could not otherwise ask for the
22
+ * bundled path where there is no bin at all.
23
+ */
24
+ export declare function selfCommand(argv?: string[], execPath?: string, findEntry?: () => string | undefined): SelfCommand;
25
+ //# sourceMappingURL=self.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self.d.ts","sourceRoot":"","sources":["../../src/daemon/self.ts"],"names":[],"mappings":"AAwCA,MAAM,WAAW,WAAW;IAC1B,+BAA+B;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AA0BD,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,OAAO,CAMvE;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,SAAS,SAAkB,GAAG,MAAM,GAAG,SAAS,CAW5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,IAAI,GAAE,MAAM,EAAiB,EAC7B,QAAQ,SAAmB,EAC3B,SAAS,GAAE,MAAM,MAAM,GAAG,SAAwB,GACjD,WAAW,CAoBb"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * How this package runs its own CLI again.
3
+ *
4
+ * The daemon is not a second program: it is this package's `bin` started with
5
+ * `serve --daemon`. `ensureDaemon()` has to spawn it, and the only hard part is
6
+ * working out what "this package's bin" is from inside a running process.
7
+ *
8
+ * It is **not** `process.argv[1]`. That is whatever script started the process,
9
+ * which is this bin only when somebody typed `page-scanner`. Inside
10
+ * `@page-scanner/mcp` it is the MCP server's bin, and re-running that with
11
+ * `serve --daemon` starts a second MCP server on stdio: nothing binds 45711,
12
+ * and every tool call fails with "the daemon did not come up within 5s" for
13
+ * ever. Inside a library consumer it is the consumer's own entry file, which
14
+ * then runs again, detached, side effects and all, once every retry. Both were
15
+ * reproduced against the packed tarballs before this was written.
16
+ *
17
+ * This module ships next to the bin it has to spawn, so it can find it without
18
+ * asking the process anything: `dist/daemon/self.js` sits under `dist/bin.js`,
19
+ * and `src/daemon/self.ts` under `src/bin.ts` when Node runs the TypeScript
20
+ * directly. Taking the extension from this module's own path covers both.
21
+ *
22
+ * Two layouts have no such file, and each has its own answer:
23
+ * - A `bun build --compile` executable embeds the script, so the binary is
24
+ * spawned on its own. A probe compiled with bun 1.4.2 on darwin-arm64 reports
25
+ * `argv = ['bun', '/$bunfs/root/<binary>', ...args]` and `execPath = <the
26
+ * binary>`, which is what `isCompiledBinary` reads. The Windows `B:\~BUN\`
27
+ * form is still an assumption, because nobody has run the compiled .exe yet.
28
+ * - A single-file esbuild bundle, which is what the `.mcpb` ships, inlines the
29
+ * daemon and everything else into `argv[1]`. Falling back to `argv[1]` is
30
+ * right there, and both bundled entry points answer `serve --daemon`.
31
+ *
32
+ * Getting any of it wrong is not silent: the spawned process fails to start or
33
+ * prints usage, and `ensureDaemon()` gives up with DAEMON_FAILED.
34
+ */
35
+ import { existsSync } from 'node:fs';
36
+ import { dirname, extname, join } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { PageScannerError } from '../errors.js';
39
+ /**
40
+ * What Bun puts in `argv[1]` inside a compiled binary: a path into the virtual
41
+ * filesystem it embeds the script in, which exists only inside that process.
42
+ * Spawning it would fail, so it counts as no script.
43
+ * Compared case-insensitively with `\` folded to `/`, because the Windows form
44
+ * is a fake drive letter.
45
+ */
46
+ const EMBEDDED_SCRIPT_PREFIXES = ['/$bunfs/', 'b:/~bun/', '/~bun/'];
47
+ /**
48
+ * `process.versions.bun` is not part of @types/node. The interface does carry
49
+ * an index signature, so the read happens to compile, but that is an accident
50
+ * of the typings rather than a contract: name the shape we rely on instead.
51
+ */
52
+ function bunVersion() {
53
+ const versions = process.versions;
54
+ return versions.bun;
55
+ }
56
+ function isEmbeddedScriptPath(path) {
57
+ const normalised = path.replaceAll('\\', '/').toLowerCase();
58
+ return EMBEDDED_SCRIPT_PREFIXES.some((prefix) => normalised.startsWith(prefix));
59
+ }
60
+ /** True when running inside a `bun build --compile` single-file executable. */
61
+ export function isCompiledBinary(argv = process.argv) {
62
+ // Running under Bun is necessary but not sufficient: `bun ./src/bin.ts` is
63
+ // Bun too, and that one does have a script to hand back.
64
+ if (bunVersion() === undefined)
65
+ return false;
66
+ const script = argv[1];
67
+ return script === undefined || script === '' || isEmbeddedScriptPath(script);
68
+ }
69
+ /**
70
+ * This package's own `bin`, found from this module's location rather than from
71
+ * the process. Undefined when there is no file there, which means the code has
72
+ * been bundled into one file and the caller should fall back to `argv[1]`.
73
+ */
74
+ export function packageEntry(moduleUrl = import.meta.url) {
75
+ let here;
76
+ try {
77
+ here = fileURLToPath(moduleUrl);
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ // `bin` carries this module's own extension, so `dist/daemon/self.js` finds
83
+ // `dist/bin.js` and `src/daemon/self.ts` finds `src/bin.ts` with no branch.
84
+ const entry = join(dirname(here), '..', `bin${extname(here)}`);
85
+ return existsSync(entry) ? entry : undefined;
86
+ }
87
+ /**
88
+ * How to run this package's CLI again.
89
+ * @param argv defaults to process.argv, injectable for tests.
90
+ * @param execPath defaults to process.execPath, injectable for tests.
91
+ * @param findEntry how to locate this package's bin, injectable for tests. It
92
+ * is a function rather than a value because a default parameter fires on an
93
+ * explicit `undefined` too, so a test could not otherwise ask for the
94
+ * bundled path where there is no bin at all.
95
+ */
96
+ export function selfCommand(argv = process.argv, execPath = process.execPath, findEntry = packageEntry) {
97
+ if (isCompiledBinary(argv))
98
+ return { command: execPath, args: [] };
99
+ const entry = findEntry();
100
+ if (entry !== undefined)
101
+ return { command: execPath, args: [entry] };
102
+ // Bundled into one file: that file is both this module and the entry point,
103
+ // and `argv[1]` is where it lives.
104
+ const script = argv[1];
105
+ if (script === undefined || script === '') {
106
+ // Node was started with no script — `-e`, `--eval`, a REPL, or an embedder
107
+ // — and there is no bin on disk to fall back to. Guessing would spawn the
108
+ // wrong thing.
109
+ throw new PageScannerError('DAEMON_FAILED', 'page-scanner cannot work out how to restart itself: this process was started without a script path.', 'Start the daemon yourself with `page-scanner serve`, or run the CLI from its installed `page-scanner` command.');
110
+ }
111
+ return { command: execPath, args: [script] };
112
+ }
113
+ //# sourceMappingURL=self.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self.js","sourceRoot":"","sources":["../../src/daemon/self.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAShD;;;;;;GAMG;AACH,MAAM,wBAAwB,GAAG,CAAC,UAAU,EAAE,UAAU,EAAE,QAAQ,CAAU,CAAC;AAE7E;;;;GAIG;AACH,SAAS,UAAU;IACjB,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAwD,CAAC;IAClF,OAAO,QAAQ,CAAC,GAAG,CAAC;AACtB,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAY;IACxC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC;IAC5D,OAAO,wBAAwB,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;AAClF,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAAC,OAAiB,OAAO,CAAC,IAAI;IAC5D,2EAA2E;IAC3E,yDAAyD;IACzD,IAAI,UAAU,EAAE,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACvB,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,IAAI,oBAAoB,CAAC,MAAM,CAAC,CAAC;AAC/E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG;IACtD,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,4EAA4E;IAC5E,4EAA4E;IAC5E,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/D,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAC/C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CACzB,OAAiB,OAAO,CAAC,IAAI,EAC7B,QAAQ,GAAG,OAAO,CAAC,QAAQ,EAC3B,YAAsC,YAAY;IAElD,IAAI,gBAAgB,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IAEnE,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;IAC1B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC;IAErE,4EAA4E;IAC5E,mCAAmC;IACnC,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACvB,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;QAC1C,2EAA2E;QAC3E,0EAA0E;QAC1E,eAAe;QACf,MAAM,IAAI,gBAAgB,CACxB,eAAe,EACf,qGAAqG,EACrG,gHAAgH,CACjH,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,25 @@
1
+ export interface DaemonState {
2
+ pid: number;
3
+ /** The loopback HTTP port the daemon answers RPC on. Ephemeral, chosen at start. */
4
+ rpcPort: number;
5
+ /** Bearer secret for that port, regenerated every run. */
6
+ secret: string;
7
+ /** The WebSocket port the extension dials, from the pairing config. */
8
+ bridgePort: number;
9
+ /** The CLI version that started it, so a client can restart a stale one. */
10
+ version: string;
11
+ startedAt: number;
12
+ }
13
+ export declare function daemonStatePath(): string;
14
+ export declare function daemonLogPath(): string;
15
+ /**
16
+ * Reads the record, or null if there is nothing usable to read: no file, bad
17
+ * JSON, a field missing or of the wrong type, or a pid that no longer exists.
18
+ * It never throws — it is on the path every command takes before it can do
19
+ * anything, and a corrupt file there should cost a restart, not a crash.
20
+ */
21
+ export declare function readDaemonState(): DaemonState | null;
22
+ export declare function writeDaemonState(state: DaemonState): void;
23
+ export declare function clearDaemonState(): void;
24
+ export declare function processAlive(pid: number): boolean;
25
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/daemon/state.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,oFAAoF;IACpF,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAWD;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,WAAW,GAAG,IAAI,CAqBpD;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAazD;AAED,wBAAgB,gBAAgB,IAAI,IAAI,CAQvC;AAQD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAiBjD"}