@opencraw/driver 0.0.0-stage → 0.2.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.
package/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Open-Craw/OpenCraw/main/docs/assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # @opencraw/driver
6
+
7
+ [OpenCraw](https://github.com/Open-Craw/OpenCraw/blob/main/README.md)'s engine as a process another language talks
8
+ to. A client in Python, Go, Dart, C#, Rust, Java, Ruby, PHP or C++ starts `opencraw-driver` once, and speaks
9
+ **JSON-RPC 2.0 over its stdin and stdout, one message per line**. It sends recipes, gets every crawl event and
10
+ record as it happens, and answers the recipes' hooks with functions in its own language. This is how the
11
+ OpenCraw packages for other languages work (`docs/research/driver-and-language-packages.md`).
12
+
13
+ ```sh
14
+ npx opencraw-driver # then write JSON-RPC lines to it
15
+ ```
16
+
17
+ ## The conversation
18
+
19
+ ```jsonc
20
+ → {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientName":"opencraw-python","clientVersion":"1.0.0","protocolVersion":"1.1.0"}}
21
+ ← {"jsonrpc":"2.0","id":1,"result":{"driverVersion":"0.2.0","protocolVersion":"1.1.0","methods":["initialize","validate","run","cancel","probe","diff","export","shutdown"]}}
22
+ → {"jsonrpc":"2.0","id":2,"method":"run","params":{"paths":["recipes/"],"hooks":["tidy"]}}
23
+ ← {"jsonrpc":"2.0","id":2,"result":{"runId":"run-1"}}
24
+ ← {"jsonrpc":"2.0","method":"event","params":{"runId":"run-1","event":{"type":"recipe:start",…}}}
25
+ ← {"jsonrpc":"2.0","id":1,"method":"callout","params":{"runId":"run-1","request":{"kind":"hook","name":"tidy","input":" A ",…}}}
26
+ → {"jsonrpc":"2.0","id":1,"result":{"status":"ok","output":"a"}}
27
+ ← {"jsonrpc":"2.0","method":"record","params":{"runId":"run-1","recipeId":"things","key":"…","data":{…}}}
28
+ ← {"jsonrpc":"2.0","method":"finished","params":{"runId":"run-1","report":{…}}}
29
+ → {"jsonrpc":"2.0","id":3,"method":"shutdown"}
30
+ ```
31
+
32
+ | Method | From | Does |
33
+ |---|---|---|
34
+ | `initialize` | client | Must come first. The driver refuses a protocol of another major. |
35
+ | `validate` | client | Loads recipes (`paths` or inline `recipes`) without running them: the inputs that would run, every hook they call, and every problem with its path. |
36
+ | `run` | client | Starts a run and answers its `runId` at once; the crawl goes on in the background. `hooks`, `captchaSolvers` and `accessPlugins` name what the client answers; `accessConfig` and `access` pick the access config and profile; `out` writes records to a file instead of sending them. |
37
+ | `cancel` | client | Stops a run (its crawler is closed); it still ends with `finished`. |
38
+ | `probe` | client | Fetches a page (or renders it, with `browser`) and says where its data lives: the CLI's and MCP's probe, through the same access fields as `run`. |
39
+ | `diff` | client | Compares two runs' JSON Lines files by key: counts, the report a person reads, and the changes up to `limit`. |
40
+ | `export` | client | Writes a run's JSON Lines file in another format (the extension of `out`), with an output recipe's columns. |
41
+ | `shutdown` | client | Closes every run, answers, then exits. Closing stdin does the same. |
42
+ | `event` | driver | Every crawl event, as core reports it. |
43
+ | `record` | driver | Each record, unless the run has `out`. |
44
+ | `finished` | driver | The run is over: its report, or why it could not run. |
45
+ | `callout` | driver | A hook, captcha solver or access plugin the client serves (`request.kind`: `hook`, `captcha`, `access`). The request and its answer are core's [callout contract](../core/README.md): `ok`, `error`, or `pending` with `retryAfterMs`, asked again with the same idempotency key. |
46
+
47
+ Every answer comes back before the driver stops, even when a client writes its last requests and closes
48
+ stdin at once. Answers may arrive out of order; match them by `id`.
49
+
50
+ ## For client authors
51
+
52
+ - `schemas/driver-protocol.schema.json` is every message's shape, generated from the protocol's zod
53
+ contracts. Generate your models from it rather than typing them by hand.
54
+ - `conformance/*.json` are recorded conversations. A client is done when it replays them all against the real
55
+ driver. See `conformance/README.md`.
56
+ - Errors are JSON-RPC's codes: `-32700` (not JSON), `-32600` (not JSON-RPC), `-32601` (no such method),
57
+ `-32602` (bad params, the problems listed), `-32000` (a failure to act on: its message says what to do).
58
+ - stdout carries protocol messages only. The driver's own logs go to stderr.
59
+
60
+ ## Development
61
+
62
+ `smith run lint typecheck test build -p driver`. After changing a contract in `src/protocol/`, run
63
+ `smith run schemas -p driver` to rewrite `schemas/driver-protocol.schema.json`; a test fails until you do.
@@ -0,0 +1,513 @@
1
+ // src/server/serve-driver.use-case.ts
2
+ import { once } from "node:events";
3
+
4
+ // src/protocol/driver-message.contract.ts
5
+ import { calloutRequestSchema, calloutResponseSchema } from "@opencraw/core";
6
+ import { z } from "zod";
7
+ var PROTOCOL_VERSION = "1.1.0";
8
+ var recipeObject = z.record(z.string(), z.unknown());
9
+ var coreObject = z.record(z.string(), z.unknown());
10
+ var accessFields = {
11
+ accessConfig: z.string().optional(),
12
+ access: z.string().optional(),
13
+ /** Access plugins the client serves: a profile `{ "kind": "plugin", "name": … }` naming one leases through a `callout` to the client. */
14
+ accessPlugins: z.array(z.string()).optional()
15
+ };
16
+ var recipeSource = {
17
+ paths: z.array(z.string()).min(1).optional(),
18
+ recipes: z.array(recipeObject).min(1).optional()
19
+ };
20
+ var initializeParamsSchema = z.strictObject({
21
+ clientName: z.string().min(1),
22
+ clientVersion: z.string().min(1),
23
+ protocolVersion: z.string().regex(/^\d+\.\d+\.\d+$/)
24
+ });
25
+ var initializeResultSchema = z.strictObject({
26
+ driverVersion: z.string(),
27
+ protocolVersion: z.string(),
28
+ methods: z.array(z.string())
29
+ });
30
+ var validateParamsSchema = z.strictObject({ ...recipeSource, only: z.array(z.string()).optional() });
31
+ var issueSchema = z.strictObject({ source: z.string().optional(), recipeId: z.string().optional(), path: z.string(), message: z.string() });
32
+ var validateResultSchema = z.strictObject({
33
+ ok: z.boolean(),
34
+ /** The input recipes that would run, when ok. */
35
+ inputs: z.array(z.string()),
36
+ /** Every hook the recipes call, which a `run` must serve. */
37
+ hooks: z.array(z.string()),
38
+ issues: z.array(issueSchema)
39
+ });
40
+ var runParamsSchema = z.strictObject({
41
+ ...recipeSource,
42
+ only: z.array(z.string()).optional(),
43
+ /** A file to write the records to, its extension naming the format; without it, each record is sent as a `record` notification. */
44
+ out: z.string().optional(),
45
+ append: z.boolean().optional(),
46
+ resume: z.boolean().optional(),
47
+ dryRun: z.boolean().optional(),
48
+ parallel: z.number().int().min(1).max(16).optional(),
49
+ browser: z.strictObject({ headless: z.boolean().optional(), executablePath: z.string().optional(), ignoreHTTPSErrors: z.boolean().optional() }).optional(),
50
+ /** The hooks the client answers itself: each becomes a `callout` request to the client when a recipe calls it. */
51
+ hooks: z.array(z.string()).optional(),
52
+ /** Captcha solvers the client serves, by name: a challenge goes to the client as a `captcha` callout. */
53
+ captchaSolvers: z.array(z.string()).optional(),
54
+ ...accessFields
55
+ });
56
+ var runResultSchema = z.strictObject({ runId: z.string() });
57
+ var cancelParamsSchema = z.strictObject({ runId: z.string() });
58
+ var cancelResultSchema = z.strictObject({ cancelled: z.boolean() });
59
+ var probeParamsSchema = z.strictObject({
60
+ url: z.string().min(1),
61
+ /** Render the page in Chromium first (for pages built by scripts). */
62
+ browser: z.boolean().optional(),
63
+ browserPath: z.string().optional(),
64
+ insecureTls: z.boolean().optional(),
65
+ userAgent: z.string().optional(),
66
+ ...accessFields
67
+ });
68
+ var probeResultSchema = coreObject;
69
+ var diffParamsSchema = z.strictObject({
70
+ /** The earlier run's JSON Lines file. */
71
+ previous: z.string().min(1),
72
+ current: z.string().min(1),
73
+ /** The fields that identify a record; default: each line's `_key`. */
74
+ key: z.array(z.string()).optional(),
75
+ /** Fields left out of the comparison. */
76
+ ignore: z.array(z.string()).optional(),
77
+ /** Changes returned at most (default 200); the counts cover all of them. */
78
+ limit: z.number().int().min(0).optional()
79
+ });
80
+ var diffResultSchema = z.strictObject({
81
+ added: z.number(),
82
+ removed: z.number(),
83
+ changed: z.number(),
84
+ unchanged: z.number(),
85
+ /** Set when the current run lost most of the previous run's records. */
86
+ shrunk: z.strictObject({ previous: z.number(), current: z.number() }).optional(),
87
+ /** The report a person reads, one line per change. */
88
+ summary: z.array(z.string()),
89
+ /** The changes, up to `limit`: each `added`, `removed` or `changed` (with each field's before and after). */
90
+ changes: z.array(coreObject),
91
+ /** More changes than `limit` were found. */
92
+ truncated: z.boolean()
93
+ });
94
+ var exportParamsSchema = z.strictObject({
95
+ /** A run's JSON Lines file. */
96
+ records: z.string().min(1),
97
+ /** The output recipe that gives the columns. */
98
+ output: z.string().min(1),
99
+ /** The file to write; its extension names the format, as for `run`. */
100
+ out: z.string().min(1)
101
+ });
102
+ var exportResultSchema = z.strictObject({ written: z.number(), location: z.string() });
103
+ var shutdownResultSchema = z.strictObject({});
104
+ var eventParamsSchema = z.strictObject({ runId: z.string(), event: z.record(z.string(), z.unknown()) });
105
+ var recordParamsSchema = z.strictObject({ runId: z.string(), recipeId: z.string(), key: z.string().nullable(), data: z.record(z.string(), z.unknown()) });
106
+ var finishedParamsSchema = z.strictObject({
107
+ runId: z.string(),
108
+ /** The crawl report (`CrawlReport`), absent when the run could not start. */
109
+ report: z.record(z.string(), z.unknown()).optional(),
110
+ /** Why the run could not start, or stopped as a whole. */
111
+ error: z.string().optional()
112
+ });
113
+ var calloutParamsSchema = z.strictObject({ runId: z.string(), request: calloutRequestSchema });
114
+ var calloutResultSchema = calloutResponseSchema;
115
+ var MESSAGES = {
116
+ initialize: { direction: "client-to-driver", params: initializeParamsSchema, result: initializeResultSchema },
117
+ validate: { direction: "client-to-driver", params: validateParamsSchema, result: validateResultSchema },
118
+ run: { direction: "client-to-driver", params: runParamsSchema, result: runResultSchema },
119
+ cancel: { direction: "client-to-driver", params: cancelParamsSchema, result: cancelResultSchema },
120
+ probe: { direction: "client-to-driver", params: probeParamsSchema, result: probeResultSchema },
121
+ diff: { direction: "client-to-driver", params: diffParamsSchema, result: diffResultSchema },
122
+ export: { direction: "client-to-driver", params: exportParamsSchema, result: exportResultSchema },
123
+ shutdown: { direction: "client-to-driver", params: void 0, result: shutdownResultSchema },
124
+ callout: { direction: "driver-to-client", params: calloutParamsSchema, result: calloutResultSchema },
125
+ event: { direction: "driver-to-client", params: eventParamsSchema, result: void 0 },
126
+ record: { direction: "driver-to-client", params: recordParamsSchema, result: void 0 },
127
+ finished: { direction: "driver-to-client", params: finishedParamsSchema, result: void 0 }
128
+ };
129
+
130
+ // src/protocol/driver-json-schema.mapper.ts
131
+ import { z as z2 } from "zod";
132
+ var OPTIONS = { target: "draft-2020-12", io: "output", unrepresentable: "any" };
133
+ function driverProtocolJsonSchema() {
134
+ const defs = {};
135
+ const methods = {};
136
+ for (const [name, message] of Object.entries(MESSAGES)) {
137
+ const entry = { direction: message.direction };
138
+ if (message.params !== void 0) {
139
+ defs[`${name}.params`] = z2.toJSONSchema(message.params, OPTIONS);
140
+ entry.params = { $ref: `#/$defs/${name}.params` };
141
+ }
142
+ if (message.result !== void 0) {
143
+ defs[`${name}.result`] = z2.toJSONSchema(message.result, OPTIONS);
144
+ entry.result = { $ref: `#/$defs/${name}.result` };
145
+ }
146
+ methods[name] = entry;
147
+ }
148
+ return {
149
+ $schema: "https://json-schema.org/draft/2020-12/schema",
150
+ $id: "https://opencraw/schemas/driver-protocol.schema.json",
151
+ title: "OpenCraw driver protocol",
152
+ description: "JSON-RPC 2.0 over stdio, one message per line. Methods with a result are requests; the rest are notifications.",
153
+ protocolVersion: PROTOCOL_VERSION,
154
+ methods,
155
+ $defs: defs
156
+ };
157
+ }
158
+
159
+ // src/record-files/diff-record-files.use-case.ts
160
+ import { diffReport } from "@opencraw/cli";
161
+ import { diffRecords, readRecordsFile } from "@opencraw/core";
162
+ var CHANGE_LIMIT = 200;
163
+ async function diffRecordFiles(params) {
164
+ const [previous, current] = await Promise.all([readRecordsFile(params.previous), readRecordsFile(params.current)]);
165
+ const diff = diffRecords(previous, current, { key: params.key, ignore: params.ignore });
166
+ const limit = params.limit ?? CHANGE_LIMIT;
167
+ return {
168
+ added: diff.added,
169
+ removed: diff.removed,
170
+ changed: diff.changed,
171
+ unchanged: diff.unchanged,
172
+ ...diff.shrunk !== void 0 && { shrunk: diff.shrunk },
173
+ summary: diffReport(diff),
174
+ changes: diff.changes.slice(0, limit).map((change) => ({ ...change })),
175
+ truncated: diff.changes.length > limit
176
+ };
177
+ }
178
+
179
+ // src/record-files/export-record-files.use-case.ts
180
+ import { readFile } from "node:fs/promises";
181
+ import { fileSink, outputRecordOf, parseOutputRecipe, readRecordsFile as readRecordsFile2 } from "@opencraw/core";
182
+ async function exportRecordFiles(params) {
183
+ const output = parseOutputRecipe(JSON.parse(await readFile(params.output, "utf8")), params.output);
184
+ const stored = await readRecordsFile2(params.records);
185
+ const sink = fileSink(params.out, { browser: { executablePath: process.env.OPENCRAW_CHROMIUM } });
186
+ await sink.open(output);
187
+ for (const line of stored) await sink.write(outputRecordOf(line));
188
+ const summary = await sink.close();
189
+ return { written: summary.written, location: summary.location ?? params.out };
190
+ }
191
+
192
+ // src/session/run-session.use-case.ts
193
+ import { createCrawler, fileSink as fileSink2, RecipeSet } from "@opencraw/core";
194
+
195
+ // src/callout-bridge/client-hooks.client.ts
196
+ import { accessPluginVia, calloutHook, calloutResponseSchema as calloutResponseSchema2, captchaSolverVia } from "@opencraw/core";
197
+ function clientTransport(label, runId, ask) {
198
+ return {
199
+ label,
200
+ call: async (request) => calloutResponseSchema2.parse(await ask("callout", { runId, request }))
201
+ };
202
+ }
203
+ function clientHooks(names, runId, ask) {
204
+ return Object.fromEntries(names.map((name) => [name, calloutHook(name, clientTransport(`client hook ${name}`, runId, ask))]));
205
+ }
206
+ function clientCaptchaSolvers(names, runId, ask) {
207
+ return names.map((name) => captchaSolverVia(name, clientTransport(`client captcha solver ${name}`, runId, ask)));
208
+ }
209
+ function clientAccessPlugins(names, runId, ask) {
210
+ return names.map((name) => accessPluginVia(name, clientTransport(`client access plugin ${name}`, runId, ask)));
211
+ }
212
+
213
+ // src/site-access/resolve-site-access.use-case.ts
214
+ import { resolveAccess } from "@opencraw/cli";
215
+ async function resolveSiteAccess(params, runId, ask, env = process.env) {
216
+ const file = params.accessConfig ?? env.OPENCRAW_ACCESS;
217
+ if (file === void 0 && params.access !== void 0) throw new Error(`access profile "${params.access}" needs an access config: give "accessConfig", or set OPENCRAW_ACCESS where the driver runs`);
218
+ const access = await resolveAccess({ insecureTls: false, access: file, accessProfile: params.access });
219
+ return { access, accessPlugins: clientAccessPlugins(params.accessPlugins ?? [], runId, ask) };
220
+ }
221
+
222
+ // src/session/recipe-loading.use-case.ts
223
+ import { loadForRun } from "@opencraw/cli";
224
+ import { hookUses, RecipeBindingError, RecipeValidationError } from "@opencraw/core";
225
+ function sourceOf(params) {
226
+ if (params.paths === void 0 === (params.recipes === void 0)) throw new Error('give exactly one of "paths" or "recipes"');
227
+ return params.paths ?? params.recipes ?? [];
228
+ }
229
+ async function loadRecipes(params) {
230
+ return await loadForRun(sourceOf(params), params.only ?? []);
231
+ }
232
+ function hooksOf(set) {
233
+ return [...new Set(set.inputs.flatMap((input) => hookUses(input).map((use) => use.name)))].sort((x, y) => x.localeCompare(y));
234
+ }
235
+ async function validateRecipes(params) {
236
+ try {
237
+ const set = await loadRecipes(params);
238
+ return { ok: true, inputs: set.inputs.map((input) => input.id), hooks: hooksOf(set), issues: [] };
239
+ } catch (error) {
240
+ if (error instanceof RecipeValidationError) return { ok: false, inputs: [], hooks: [], issues: error.issues.map((issue) => ({ source: error.source, path: issue.path, message: issue.message })) };
241
+ if (error instanceof RecipeBindingError) return { ok: false, inputs: [], hooks: [], issues: error.issues.map((issue) => ({ recipeId: issue.recipeId, path: issue.path, message: issue.message })) };
242
+ return { ok: false, inputs: [], hooks: [], issues: [{ path: "", message: error instanceof Error ? error.message : String(error) }] };
243
+ }
244
+ }
245
+
246
+ // src/session/streaming-sink.repository.ts
247
+ function streamingSink() {
248
+ let written = 0;
249
+ return {
250
+ open: async () => {
251
+ },
252
+ write: async () => {
253
+ written += 1;
254
+ },
255
+ close: async () => ({ written })
256
+ };
257
+ }
258
+
259
+ // src/session/run-session.use-case.ts
260
+ function runSessions(notify, ask) {
261
+ const running = /* @__PURE__ */ new Map();
262
+ let next = 1;
263
+ const crawl = async (runId, crawler, set) => {
264
+ try {
265
+ const report = await crawler.run(set);
266
+ notify("finished", { runId, report });
267
+ } catch (error) {
268
+ notify("finished", { runId, error: error instanceof Error ? error.message : String(error) });
269
+ } finally {
270
+ running.delete(runId);
271
+ await crawler.close();
272
+ }
273
+ };
274
+ return {
275
+ start: async (params) => {
276
+ const loaded = await loadRecipes(params);
277
+ const set = params.dryRun === true ? new RecipeSet(loaded.output, loaded.inputs.map((input) => ({ ...input, limits: { ...input.limits, maxRecords: 1 } }))) : loaded;
278
+ const runId = `run-${next++}`;
279
+ const { access, accessPlugins } = await resolveSiteAccess(params, runId, ask);
280
+ const crawler = createCrawler({
281
+ sink: params.out === void 0 ? streamingSink() : fileSink2(params.out, { append: params.append, browser: { executablePath: params.browser?.executablePath } }),
282
+ hooks: clientHooks(params.hooks ?? [], runId, ask),
283
+ captchaSolvers: clientCaptchaSolvers(params.captchaSolvers ?? [], runId, ask),
284
+ access,
285
+ accessPlugins,
286
+ profilesDir: process.env.OPENCRAW_PROFILES,
287
+ parallel: params.parallel,
288
+ resume: params.resume,
289
+ debug: params.dryRun,
290
+ browser: { headless: params.browser?.headless ?? true, executablePath: params.browser?.executablePath, ignoreHTTPSErrors: params.browser?.ignoreHTTPSErrors },
291
+ onEvent: (event) => {
292
+ notify("event", { runId, event });
293
+ if (event.type === "record:emit" && params.out === void 0) notify("record", { runId, recipeId: event.recipeId, key: event.key, data: event.data });
294
+ }
295
+ });
296
+ running.set(runId, crawler);
297
+ void crawl(runId, crawler, set);
298
+ return runId;
299
+ },
300
+ cancel: async (runId) => {
301
+ const crawler = running.get(runId);
302
+ if (crawler === void 0) return false;
303
+ running.delete(runId);
304
+ await crawler.close();
305
+ return true;
306
+ },
307
+ closeAll: async () => {
308
+ const crawlers = running.values().toArray();
309
+ running.clear();
310
+ await Promise.all(crawlers.map(async (crawler) => {
311
+ await crawler.close();
312
+ }));
313
+ }
314
+ };
315
+ }
316
+
317
+ // src/site-probe/probe-site.use-case.ts
318
+ import { probeUrl } from "@opencraw/cli";
319
+ async function probeSite(params, probeId, ask) {
320
+ const { access, accessPlugins } = await resolveSiteAccess(params, probeId, ask);
321
+ const result = await probeUrl(params.url, {
322
+ browser: params.browser ?? false,
323
+ browserPath: params.browserPath ?? process.env.OPENCRAW_CHROMIUM,
324
+ insecureTls: params.insecureTls ?? false,
325
+ userAgent: params.userAgent,
326
+ accessConfig: access,
327
+ accessPlugins
328
+ });
329
+ return { ...result };
330
+ }
331
+
332
+ // src/transport/line-channel.client.ts
333
+ import { createInterface } from "node:readline";
334
+
335
+ // src/transport/json-rpc.contract.ts
336
+ var RPC_ERRORS = {
337
+ parse: -32700,
338
+ invalidRequest: -32600,
339
+ methodNotFound: -32601,
340
+ invalidParams: -32602,
341
+ internal: -32603,
342
+ /** A failure the caller must act on: the message says what to do. */
343
+ server: -32e3
344
+ };
345
+ var RpcFailure = class extends Error {
346
+ code;
347
+ data;
348
+ constructor(code, message, data) {
349
+ super(message);
350
+ this.name = "RpcFailure";
351
+ this.code = code;
352
+ this.data = data;
353
+ }
354
+ };
355
+
356
+ // src/transport/line-channel.client.ts
357
+ var isRequestId = (id) => typeof id === "number" || typeof id === "string";
358
+ function openLineChannel(input, output, handle) {
359
+ const pending = /* @__PURE__ */ new Map();
360
+ const answering = /* @__PURE__ */ new Set();
361
+ const track = (answered) => {
362
+ answering.add(answered);
363
+ void (async () => {
364
+ try {
365
+ await answered;
366
+ } finally {
367
+ answering.delete(answered);
368
+ }
369
+ })();
370
+ };
371
+ let nextId = 1;
372
+ const write = (message) => {
373
+ output.write(`${JSON.stringify(message)}
374
+ `);
375
+ };
376
+ const fail = (id, error) => {
377
+ write({ jsonrpc: "2.0", id, error });
378
+ };
379
+ const answer = async (message) => {
380
+ const method = message.method;
381
+ try {
382
+ const result = await handle(method, message.params);
383
+ if (isRequestId(message.id)) write({ jsonrpc: "2.0", id: message.id, result: result ?? null });
384
+ } catch (error) {
385
+ if (!isRequestId(message.id)) return;
386
+ if (error instanceof RpcFailure) fail(message.id, { code: error.code, message: error.message, ...error.data !== void 0 && { data: error.data } });
387
+ else fail(message.id, { code: RPC_ERRORS.server, message: error instanceof Error ? error.message : String(error) });
388
+ }
389
+ };
390
+ const settle = (message) => {
391
+ const waiting = typeof message.id === "number" ? pending.get(message.id) : void 0;
392
+ if (waiting === void 0) return;
393
+ pending.delete(message.id);
394
+ if (message.error === void 0) waiting.resolve(message.result);
395
+ else waiting.reject(new RpcFailure(message.error.code, message.error.message, message.error.data));
396
+ };
397
+ const lines = createInterface({ input, crlfDelay: Infinity });
398
+ lines.on("line", (line) => {
399
+ if (line.trim() === "") return;
400
+ let message;
401
+ try {
402
+ message = JSON.parse(line);
403
+ } catch {
404
+ fail(null, { code: RPC_ERRORS.parse, message: "the line is not JSON" });
405
+ return;
406
+ }
407
+ if (message === null || typeof message !== "object" || message.jsonrpc !== "2.0") {
408
+ fail(isRequestId(message?.id) ? message.id : null, { code: RPC_ERRORS.invalidRequest, message: "not a JSON-RPC 2.0 message" });
409
+ return;
410
+ }
411
+ if (typeof message.method === "string") track(answer(message));
412
+ else settle(message);
413
+ });
414
+ const closed = new Promise((resolve) => {
415
+ lines.on("close", () => {
416
+ for (const waiting of pending.values()) waiting.reject(new Error("the client closed the channel"));
417
+ pending.clear();
418
+ resolve();
419
+ });
420
+ });
421
+ return {
422
+ request: async (method, params) => await new Promise((resolve, reject) => {
423
+ const id = nextId++;
424
+ pending.set(id, { resolve, reject });
425
+ write({ jsonrpc: "2.0", id, method, params });
426
+ }),
427
+ notify: (method, params) => {
428
+ write({ jsonrpc: "2.0", method, params });
429
+ },
430
+ closed,
431
+ drained: async () => {
432
+ while (answering.size > 0) await Promise.allSettled(answering);
433
+ }
434
+ };
435
+ }
436
+
437
+ // src/server/serve-driver.use-case.ts
438
+ var METHODS = ["initialize", "validate", "run", "cancel", "probe", "diff", "export", "shutdown"];
439
+ var major = (version) => version.split(".", 1)[0];
440
+ function parsed(schema, params) {
441
+ const result = schema.safeParse(params ?? {});
442
+ if (!result.success) throw new RpcFailure(RPC_ERRORS.invalidParams, result.error.issues.map((issue) => `${issue.path.join(".") || "(params)"}: ${issue.message}`).join("; "), result.error.issues);
443
+ return result.data;
444
+ }
445
+ async function serveDriver(input, output, identity) {
446
+ let initialized = false;
447
+ const stopping = new AbortController();
448
+ let probes = 0;
449
+ const channel = openLineChannel(input, output, async (method, params) => {
450
+ if (method === "initialize") {
451
+ const hello = parsed(initializeParamsSchema, params);
452
+ if (major(hello.protocolVersion) !== major(PROTOCOL_VERSION)) throw new RpcFailure(RPC_ERRORS.server, `this driver speaks protocol ${PROTOCOL_VERSION}; ${hello.clientName} ${hello.clientVersion} asked for ${hello.protocolVersion}: use a client for protocol ${major(PROTOCOL_VERSION)}.x`);
453
+ initialized = true;
454
+ const result = { driverVersion: identity.driverVersion, protocolVersion: PROTOCOL_VERSION, methods: METHODS };
455
+ return result;
456
+ }
457
+ if (!METHODS.includes(method)) throw new RpcFailure(RPC_ERRORS.methodNotFound, `no method "${method}": the driver has ${METHODS.join(", ")}`);
458
+ if (!initialized) throw new RpcFailure(RPC_ERRORS.server, "call initialize first");
459
+ if (method === "validate") return await validateRecipes(parsed(validateParamsSchema, params));
460
+ if (method === "run") return { runId: await sessions.start(parsed(runParamsSchema, params)) };
461
+ if (method === "cancel") return { cancelled: await sessions.cancel(parsed(cancelParamsSchema, params).runId) };
462
+ if (method === "probe") return await probeSite(parsed(probeParamsSchema, params), `probe-${++probes}`, channel.request);
463
+ if (method === "diff") return await diffRecordFiles(parsed(diffParamsSchema, params));
464
+ if (method === "export") return await exportRecordFiles(parsed(exportParamsSchema, params));
465
+ await sessions.closeAll();
466
+ setImmediate(() => {
467
+ stopping.abort();
468
+ });
469
+ return {};
470
+ });
471
+ const sessions = runSessions(channel.notify, channel.request);
472
+ await Promise.race([channel.closed, once(stopping.signal, "abort")]);
473
+ await channel.drained();
474
+ await sessions.closeAll();
475
+ }
476
+ export {
477
+ MESSAGES,
478
+ PROTOCOL_VERSION,
479
+ RPC_ERRORS,
480
+ RpcFailure,
481
+ calloutParamsSchema,
482
+ calloutResultSchema,
483
+ cancelParamsSchema,
484
+ cancelResultSchema,
485
+ clientAccessPlugins,
486
+ clientCaptchaSolvers,
487
+ clientHooks,
488
+ diffParamsSchema,
489
+ diffRecordFiles,
490
+ diffResultSchema,
491
+ driverProtocolJsonSchema,
492
+ eventParamsSchema,
493
+ exportParamsSchema,
494
+ exportRecordFiles,
495
+ exportResultSchema,
496
+ finishedParamsSchema,
497
+ initializeParamsSchema,
498
+ initializeResultSchema,
499
+ openLineChannel,
500
+ probeParamsSchema,
501
+ probeResultSchema,
502
+ probeSite,
503
+ recordParamsSchema,
504
+ runParamsSchema,
505
+ runResultSchema,
506
+ runSessions,
507
+ serveDriver,
508
+ streamingSink,
509
+ validateParamsSchema,
510
+ validateRecipes,
511
+ validateResultSchema
512
+ };
513
+ //# sourceMappingURL=index.esm.js.map