@opencraw/driver 0.2.0 → 0.3.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
@@ -17,8 +17,8 @@ npx opencraw-driver # then write JSON-RPC lines to it
17
17
  ## The conversation
18
18
 
19
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"]}}
20
+ → {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientName":"opencraw-python","clientVersion":"1.0.0","protocolVersion":"1.2.0"}}
21
+ ← {"jsonrpc":"2.0","id":1,"result":{"driverVersion":"0.2.0","protocolVersion":"1.2.0","methods":["initialize","validate","run","work","cancel","probe","diff","export","shutdown"]}}
22
22
  → {"jsonrpc":"2.0","id":2,"method":"run","params":{"paths":["recipes/"],"hooks":["tidy"]}}
23
23
  ← {"jsonrpc":"2.0","id":2,"result":{"runId":"run-1"}}
24
24
  ← {"jsonrpc":"2.0","method":"event","params":{"runId":"run-1","event":{"type":"recipe:start",…}}}
@@ -34,6 +34,7 @@ npx opencraw-driver # then write JSON-RPC lines to it
34
34
  | `initialize` | client | Must come first. The driver refuses a protocol of another major. |
35
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
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
+ | `work` | client | Starts a worker pool and answers its `runId` at once. Its windows ask the client for items (`work.next`) and keep the steps marked `keep` between them; `windows` is a count or core's `WindowsPolicy`. Takes the same crawler fields as `run`. |
37
38
  | `cancel` | client | Stops a run (its crawler is closed); it still ends with `finished`. |
38
39
  | `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
40
  | `diff` | client | Compares two runs' JSON Lines files by key: counts, the report a person reads, and the changes up to `limit`. |
@@ -41,7 +42,9 @@ npx opencraw-driver # then write JSON-RPC lines to it
41
42
  | `shutdown` | client | Closes every run, answers, then exits. Closing stdin does the same. |
42
43
  | `event` | driver | Every crawl event, as core reports it. |
43
44
  | `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
+ | `finished` | driver | The run is over: its report (for `work`, the work report), or why it could not run. |
46
+ | `work.next` | driver | A window wants an item: answer `{ "item": { "id", "vars", "recipe"? } }`, holding the answer until there is one, or `{ "item": null }` to end the work. An id already queued or running runs once. |
47
+ | `work.done`, `work.failed` | driver | How an item ended: its report and records, or its outcome (`failure`, `neutral`). |
45
48
  | `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
49
 
47
50
  Every answer comes back before the driver stops, even when a client writes its last requests and closes
package/dist/index.esm.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/server/serve-driver.use-case.ts
2
- import { once } from "node:events";
2
+ import { once as once2 } from "node:events";
3
3
 
4
4
  // src/protocol/driver-message.contract.ts
5
5
  import { calloutRequestSchema, calloutResponseSchema } from "@opencraw/core";
6
6
  import { z } from "zod";
7
- var PROTOCOL_VERSION = "1.1.0";
7
+ var PROTOCOL_VERSION = "1.2.0";
8
8
  var recipeObject = z.record(z.string(), z.unknown());
9
9
  var coreObject = z.record(z.string(), z.unknown());
10
10
  var accessFields = {
@@ -54,6 +54,24 @@ var runParamsSchema = z.strictObject({
54
54
  ...accessFields
55
55
  });
56
56
  var runResultSchema = z.strictObject({ runId: z.string() });
57
+ var windowsSchema = z.union([z.number().int().min(1).max(64), z.record(z.string(), z.unknown())]);
58
+ var workParamsSchema = z.strictObject({
59
+ ...recipeSource,
60
+ only: z.array(z.string()).optional(),
61
+ out: z.string().optional(),
62
+ append: z.boolean().optional(),
63
+ browser: z.strictObject({ headless: z.boolean().optional(), executablePath: z.string().optional(), ignoreHTTPSErrors: z.boolean().optional() }).optional(),
64
+ hooks: z.array(z.string()).optional(),
65
+ captchaSolvers: z.array(z.string()).optional(),
66
+ ...accessFields,
67
+ windows: windowsSchema.optional()
68
+ });
69
+ var varValue = z.union([z.string(), z.number(), z.boolean()]);
70
+ var workItemSchema = z.strictObject({
71
+ id: z.string().min(1),
72
+ vars: z.record(z.string(), varValue),
73
+ recipe: z.string().optional()
74
+ });
57
75
  var cancelParamsSchema = z.strictObject({ runId: z.string() });
58
76
  var cancelResultSchema = z.strictObject({ cancelled: z.boolean() });
59
77
  var probeParamsSchema = z.strictObject({
@@ -101,11 +119,16 @@ var exportParamsSchema = z.strictObject({
101
119
  });
102
120
  var exportResultSchema = z.strictObject({ written: z.number(), location: z.string() });
103
121
  var shutdownResultSchema = z.strictObject({});
122
+ var workNextParamsSchema = z.strictObject({ runId: z.string() });
123
+ var workNextResultSchema = z.strictObject({ item: workItemSchema.nullable() });
124
+ var workRecordSchema = z.strictObject({ key: z.string().nullable(), data: z.record(z.string(), z.unknown()) });
125
+ var workDoneParamsSchema = z.strictObject({ runId: z.string(), item: workItemSchema, report: coreObject, records: z.array(workRecordSchema) });
126
+ var workFailedParamsSchema = z.strictObject({ runId: z.string(), item: workItemSchema, report: coreObject, outcome: z.enum(["failure", "neutral"]) });
104
127
  var eventParamsSchema = z.strictObject({ runId: z.string(), event: z.record(z.string(), z.unknown()) });
105
128
  var recordParamsSchema = z.strictObject({ runId: z.string(), recipeId: z.string(), key: z.string().nullable(), data: z.record(z.string(), z.unknown()) });
106
129
  var finishedParamsSchema = z.strictObject({
107
130
  runId: z.string(),
108
- /** The crawl report (`CrawlReport`), absent when the run could not start. */
131
+ /** The crawl report (`CrawlReport`), or for `work` the work report (`WorkReport`); absent when the run could not start. */
109
132
  report: z.record(z.string(), z.unknown()).optional(),
110
133
  /** Why the run could not start, or stopped as a whole. */
111
134
  error: z.string().optional()
@@ -113,18 +136,22 @@ var finishedParamsSchema = z.strictObject({
113
136
  var calloutParamsSchema = z.strictObject({ runId: z.string(), request: calloutRequestSchema });
114
137
  var calloutResultSchema = calloutResponseSchema;
115
138
  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 }
139
+ "initialize": { direction: "client-to-driver", params: initializeParamsSchema, result: initializeResultSchema },
140
+ "validate": { direction: "client-to-driver", params: validateParamsSchema, result: validateResultSchema },
141
+ "run": { direction: "client-to-driver", params: runParamsSchema, result: runResultSchema },
142
+ "work": { direction: "client-to-driver", params: workParamsSchema, result: runResultSchema },
143
+ "cancel": { direction: "client-to-driver", params: cancelParamsSchema, result: cancelResultSchema },
144
+ "probe": { direction: "client-to-driver", params: probeParamsSchema, result: probeResultSchema },
145
+ "diff": { direction: "client-to-driver", params: diffParamsSchema, result: diffResultSchema },
146
+ "export": { direction: "client-to-driver", params: exportParamsSchema, result: exportResultSchema },
147
+ "shutdown": { direction: "client-to-driver", params: void 0, result: shutdownResultSchema },
148
+ "callout": { direction: "driver-to-client", params: calloutParamsSchema, result: calloutResultSchema },
149
+ "work.next": { direction: "driver-to-client", params: workNextParamsSchema, result: workNextResultSchema },
150
+ "work.done": { direction: "driver-to-client", params: workDoneParamsSchema, result: void 0 },
151
+ "work.failed": { direction: "driver-to-client", params: workFailedParamsSchema, result: void 0 },
152
+ "event": { direction: "driver-to-client", params: eventParamsSchema, result: void 0 },
153
+ "record": { direction: "driver-to-client", params: recordParamsSchema, result: void 0 },
154
+ "finished": { direction: "driver-to-client", params: finishedParamsSchema, result: void 0 }
128
155
  };
129
156
 
130
157
  // src/protocol/driver-json-schema.mapper.ts
@@ -219,6 +246,66 @@ async function resolveSiteAccess(params, runId, ask, env = process.env) {
219
246
  return { access, accessPlugins: clientAccessPlugins(params.accessPlugins ?? [], runId, ask) };
220
247
  }
221
248
 
249
+ // src/work-bridge/client-work-source.client.ts
250
+ import { once } from "node:events";
251
+ function clientWorkSource(runId, ask, notify) {
252
+ const kept = [];
253
+ let ended = false;
254
+ const askClient = async () => {
255
+ const { item } = workNextResultSchema.parse(await ask("work.next", { runId }));
256
+ if (item === null) {
257
+ ended = true;
258
+ return void 0;
259
+ }
260
+ return item;
261
+ };
262
+ const keepLate = async (answer) => {
263
+ try {
264
+ const item = await answer;
265
+ if (item !== void 0) kept.push(item);
266
+ } catch {
267
+ }
268
+ };
269
+ return {
270
+ next: async (options) => {
271
+ const waiting = kept.shift();
272
+ if (waiting !== void 0) return waiting;
273
+ if (ended) return;
274
+ const answer = askClient();
275
+ const signal = options?.signal;
276
+ if (signal === void 0) return await answer;
277
+ signal.throwIfAborted();
278
+ const settled = new AbortController();
279
+ const answered = (async () => {
280
+ const item = await answer;
281
+ return { item };
282
+ })();
283
+ const retired = (async () => {
284
+ try {
285
+ await once(signal, "abort", { signal: settled.signal });
286
+ return "retired";
287
+ } catch {
288
+ return "answered";
289
+ }
290
+ })();
291
+ try {
292
+ const first = await Promise.race([answered, retired]);
293
+ if (first !== "retired" && first !== "answered") return first.item;
294
+ void keepLate(answer);
295
+ throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason));
296
+ } finally {
297
+ settled.abort();
298
+ }
299
+ },
300
+ done: (item, report, records) => {
301
+ notify("work.done", { runId, item, report, records: records.map((record) => ({ key: record.key, data: record.data })) });
302
+ },
303
+ failed: (item, report, outcome) => {
304
+ notify("work.failed", { runId, item, report, outcome });
305
+ }
306
+ };
307
+ }
308
+
222
309
  // src/session/recipe-loading.use-case.ts
223
310
  import { loadForRun } from "@opencraw/cli";
224
311
  import { hookUses, RecipeBindingError, RecipeValidationError } from "@opencraw/core";
@@ -260,41 +347,53 @@ function streamingSink() {
260
347
  function runSessions(notify, ask) {
261
348
  const running = /* @__PURE__ */ new Map();
262
349
  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
- }
350
+ const crawlerFor = async (runId, params) => {
351
+ const { access, accessPlugins } = await resolveSiteAccess(params, runId, ask);
352
+ return createCrawler({
353
+ sink: params.out === void 0 ? streamingSink() : fileSink2(params.out, { append: params.append, browser: { executablePath: params.browser?.executablePath } }),
354
+ hooks: clientHooks(params.hooks ?? [], runId, ask),
355
+ captchaSolvers: clientCaptchaSolvers(params.captchaSolvers ?? [], runId, ask),
356
+ access,
357
+ accessPlugins,
358
+ profilesDir: process.env.OPENCRAW_PROFILES,
359
+ parallel: params.parallel,
360
+ resume: params.resume,
361
+ debug: params.dryRun,
362
+ browser: { headless: params.browser?.headless ?? true, executablePath: params.browser?.executablePath, ignoreHTTPSErrors: params.browser?.ignoreHTTPSErrors },
363
+ onEvent: (event) => {
364
+ notify("event", { runId, event });
365
+ if (event.type === "record:emit" && params.out === void 0) notify("record", { runId, recipeId: event.recipeId, key: event.key, data: event.data });
366
+ }
367
+ });
368
+ };
369
+ const track = (runId, crawler, crawling) => {
370
+ running.set(runId, crawler);
371
+ void (async () => {
372
+ try {
373
+ notify("finished", { runId, report: await crawling() });
374
+ } catch (error) {
375
+ notify("finished", { runId, error: error instanceof Error ? error.message : String(error) });
376
+ } finally {
377
+ running.delete(runId);
378
+ await crawler.close();
379
+ }
380
+ })();
273
381
  };
274
382
  return {
275
383
  start: async (params) => {
276
384
  const loaded = await loadRecipes(params);
277
385
  const set = params.dryRun === true ? new RecipeSet(loaded.output, loaded.inputs.map((input) => ({ ...input, limits: { ...input.limits, maxRecords: 1 } }))) : loaded;
278
386
  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);
387
+ const crawler = await crawlerFor(runId, params);
388
+ track(runId, crawler, async () => await crawler.run(set));
389
+ return runId;
390
+ },
391
+ startWork: async (params) => {
392
+ const set = await loadRecipes(params);
393
+ const runId = `run-${next++}`;
394
+ const crawler = await crawlerFor(runId, params);
395
+ const windows = params.windows;
396
+ track(runId, crawler, async () => await crawler.work(set, clientWorkSource(runId, ask, notify), { windows }));
298
397
  return runId;
299
398
  },
300
399
  cancel: async (runId) => {
@@ -435,7 +534,7 @@ function openLineChannel(input, output, handle) {
435
534
  }
436
535
 
437
536
  // src/server/serve-driver.use-case.ts
438
- var METHODS = ["initialize", "validate", "run", "cancel", "probe", "diff", "export", "shutdown"];
537
+ var METHODS = ["initialize", "validate", "run", "work", "cancel", "probe", "diff", "export", "shutdown"];
439
538
  var major = (version) => version.split(".", 1)[0];
440
539
  function parsed(schema, params) {
441
540
  const result = schema.safeParse(params ?? {});
@@ -458,6 +557,7 @@ async function serveDriver(input, output, identity) {
458
557
  if (!initialized) throw new RpcFailure(RPC_ERRORS.server, "call initialize first");
459
558
  if (method === "validate") return await validateRecipes(parsed(validateParamsSchema, params));
460
559
  if (method === "run") return { runId: await sessions.start(parsed(runParamsSchema, params)) };
560
+ if (method === "work") return { runId: await sessions.startWork(parsed(workParamsSchema, params)) };
461
561
  if (method === "cancel") return { cancelled: await sessions.cancel(parsed(cancelParamsSchema, params).runId) };
462
562
  if (method === "probe") return await probeSite(parsed(probeParamsSchema, params), `probe-${++probes}`, channel.request);
463
563
  if (method === "diff") return await diffRecordFiles(parsed(diffParamsSchema, params));
@@ -469,7 +569,7 @@ async function serveDriver(input, output, identity) {
469
569
  return {};
470
570
  });
471
571
  const sessions = runSessions(channel.notify, channel.request);
472
- await Promise.race([channel.closed, once(stopping.signal, "abort")]);
572
+ await Promise.race([channel.closed, once2(stopping.signal, "abort")]);
473
573
  await channel.drained();
474
574
  await sessions.closeAll();
475
575
  }
@@ -485,6 +585,7 @@ export {
485
585
  clientAccessPlugins,
486
586
  clientCaptchaSolvers,
487
587
  clientHooks,
588
+ clientWorkSource,
488
589
  diffParamsSchema,
489
590
  diffRecordFiles,
490
591
  diffResultSchema,
@@ -508,6 +609,12 @@ export {
508
609
  streamingSink,
509
610
  validateParamsSchema,
510
611
  validateRecipes,
511
- validateResultSchema
612
+ validateResultSchema,
613
+ workDoneParamsSchema,
614
+ workFailedParamsSchema,
615
+ workItemSchema,
616
+ workNextParamsSchema,
617
+ workNextResultSchema,
618
+ workParamsSchema
512
619
  };
513
620
  //# sourceMappingURL=index.esm.js.map
package/dist/main.esm.js CHANGED
@@ -6,12 +6,12 @@ import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  // src/server/serve-driver.use-case.ts
9
- import { once } from "node:events";
9
+ import { once as once2 } from "node:events";
10
10
 
11
11
  // src/protocol/driver-message.contract.ts
12
12
  import { calloutRequestSchema, calloutResponseSchema } from "@opencraw/core";
13
13
  import { z } from "zod";
14
- var PROTOCOL_VERSION = "1.1.0";
14
+ var PROTOCOL_VERSION = "1.2.0";
15
15
  var recipeObject = z.record(z.string(), z.unknown());
16
16
  var coreObject = z.record(z.string(), z.unknown());
17
17
  var accessFields = {
@@ -61,6 +61,24 @@ var runParamsSchema = z.strictObject({
61
61
  ...accessFields
62
62
  });
63
63
  var runResultSchema = z.strictObject({ runId: z.string() });
64
+ var windowsSchema = z.union([z.number().int().min(1).max(64), z.record(z.string(), z.unknown())]);
65
+ var workParamsSchema = z.strictObject({
66
+ ...recipeSource,
67
+ only: z.array(z.string()).optional(),
68
+ out: z.string().optional(),
69
+ append: z.boolean().optional(),
70
+ browser: z.strictObject({ headless: z.boolean().optional(), executablePath: z.string().optional(), ignoreHTTPSErrors: z.boolean().optional() }).optional(),
71
+ hooks: z.array(z.string()).optional(),
72
+ captchaSolvers: z.array(z.string()).optional(),
73
+ ...accessFields,
74
+ windows: windowsSchema.optional()
75
+ });
76
+ var varValue = z.union([z.string(), z.number(), z.boolean()]);
77
+ var workItemSchema = z.strictObject({
78
+ id: z.string().min(1),
79
+ vars: z.record(z.string(), varValue),
80
+ recipe: z.string().optional()
81
+ });
64
82
  var cancelParamsSchema = z.strictObject({ runId: z.string() });
65
83
  var cancelResultSchema = z.strictObject({ cancelled: z.boolean() });
66
84
  var probeParamsSchema = z.strictObject({
@@ -107,11 +125,16 @@ var exportParamsSchema = z.strictObject({
107
125
  });
108
126
  var exportResultSchema = z.strictObject({ written: z.number(), location: z.string() });
109
127
  var shutdownResultSchema = z.strictObject({});
128
+ var workNextParamsSchema = z.strictObject({ runId: z.string() });
129
+ var workNextResultSchema = z.strictObject({ item: workItemSchema.nullable() });
130
+ var workRecordSchema = z.strictObject({ key: z.string().nullable(), data: z.record(z.string(), z.unknown()) });
131
+ var workDoneParamsSchema = z.strictObject({ runId: z.string(), item: workItemSchema, report: coreObject, records: z.array(workRecordSchema) });
132
+ var workFailedParamsSchema = z.strictObject({ runId: z.string(), item: workItemSchema, report: coreObject, outcome: z.enum(["failure", "neutral"]) });
110
133
  var eventParamsSchema = z.strictObject({ runId: z.string(), event: z.record(z.string(), z.unknown()) });
111
134
  var recordParamsSchema = z.strictObject({ runId: z.string(), recipeId: z.string(), key: z.string().nullable(), data: z.record(z.string(), z.unknown()) });
112
135
  var finishedParamsSchema = z.strictObject({
113
136
  runId: z.string(),
114
- /** The crawl report (`CrawlReport`), absent when the run could not start. */
137
+ /** The crawl report (`CrawlReport`), or for `work` the work report (`WorkReport`); absent when the run could not start. */
115
138
  report: z.record(z.string(), z.unknown()).optional(),
116
139
  /** Why the run could not start, or stopped as a whole. */
117
140
  error: z.string().optional()
@@ -184,6 +207,66 @@ async function resolveSiteAccess(params, runId, ask, env = process.env) {
184
207
  return { access, accessPlugins: clientAccessPlugins(params.accessPlugins ?? [], runId, ask) };
185
208
  }
186
209
 
210
+ // src/work-bridge/client-work-source.client.ts
211
+ import { once } from "node:events";
212
+ function clientWorkSource(runId, ask, notify) {
213
+ const kept = [];
214
+ let ended = false;
215
+ const askClient = async () => {
216
+ const { item } = workNextResultSchema.parse(await ask("work.next", { runId }));
217
+ if (item === null) {
218
+ ended = true;
219
+ return void 0;
220
+ }
221
+ return item;
222
+ };
223
+ const keepLate = async (answer) => {
224
+ try {
225
+ const item = await answer;
226
+ if (item !== void 0) kept.push(item);
227
+ } catch {
228
+ }
229
+ };
230
+ return {
231
+ next: async (options) => {
232
+ const waiting = kept.shift();
233
+ if (waiting !== void 0) return waiting;
234
+ if (ended) return;
235
+ const answer = askClient();
236
+ const signal = options?.signal;
237
+ if (signal === void 0) return await answer;
238
+ signal.throwIfAborted();
239
+ const settled = new AbortController();
240
+ const answered = (async () => {
241
+ const item = await answer;
242
+ return { item };
243
+ })();
244
+ const retired = (async () => {
245
+ try {
246
+ await once(signal, "abort", { signal: settled.signal });
247
+ return "retired";
248
+ } catch {
249
+ return "answered";
250
+ }
251
+ })();
252
+ try {
253
+ const first = await Promise.race([answered, retired]);
254
+ if (first !== "retired" && first !== "answered") return first.item;
255
+ void keepLate(answer);
256
+ throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason));
257
+ } finally {
258
+ settled.abort();
259
+ }
260
+ },
261
+ done: (item, report, records) => {
262
+ notify("work.done", { runId, item, report, records: records.map((record) => ({ key: record.key, data: record.data })) });
263
+ },
264
+ failed: (item, report, outcome) => {
265
+ notify("work.failed", { runId, item, report, outcome });
266
+ }
267
+ };
268
+ }
269
+
187
270
  // src/session/recipe-loading.use-case.ts
188
271
  import { loadForRun } from "@opencraw/cli";
189
272
  import { hookUses, RecipeBindingError, RecipeValidationError } from "@opencraw/core";
@@ -225,41 +308,53 @@ function streamingSink() {
225
308
  function runSessions(notify, ask) {
226
309
  const running = /* @__PURE__ */ new Map();
227
310
  let next = 1;
228
- const crawl = async (runId, crawler, set) => {
229
- try {
230
- const report = await crawler.run(set);
231
- notify("finished", { runId, report });
232
- } catch (error) {
233
- notify("finished", { runId, error: error instanceof Error ? error.message : String(error) });
234
- } finally {
235
- running.delete(runId);
236
- await crawler.close();
237
- }
311
+ const crawlerFor = async (runId, params) => {
312
+ const { access, accessPlugins } = await resolveSiteAccess(params, runId, ask);
313
+ return createCrawler({
314
+ sink: params.out === void 0 ? streamingSink() : fileSink2(params.out, { append: params.append, browser: { executablePath: params.browser?.executablePath } }),
315
+ hooks: clientHooks(params.hooks ?? [], runId, ask),
316
+ captchaSolvers: clientCaptchaSolvers(params.captchaSolvers ?? [], runId, ask),
317
+ access,
318
+ accessPlugins,
319
+ profilesDir: process.env.OPENCRAW_PROFILES,
320
+ parallel: params.parallel,
321
+ resume: params.resume,
322
+ debug: params.dryRun,
323
+ browser: { headless: params.browser?.headless ?? true, executablePath: params.browser?.executablePath, ignoreHTTPSErrors: params.browser?.ignoreHTTPSErrors },
324
+ onEvent: (event) => {
325
+ notify("event", { runId, event });
326
+ if (event.type === "record:emit" && params.out === void 0) notify("record", { runId, recipeId: event.recipeId, key: event.key, data: event.data });
327
+ }
328
+ });
329
+ };
330
+ const track = (runId, crawler, crawling) => {
331
+ running.set(runId, crawler);
332
+ void (async () => {
333
+ try {
334
+ notify("finished", { runId, report: await crawling() });
335
+ } catch (error) {
336
+ notify("finished", { runId, error: error instanceof Error ? error.message : String(error) });
337
+ } finally {
338
+ running.delete(runId);
339
+ await crawler.close();
340
+ }
341
+ })();
238
342
  };
239
343
  return {
240
344
  start: async (params) => {
241
345
  const loaded = await loadRecipes(params);
242
346
  const set = params.dryRun === true ? new RecipeSet(loaded.output, loaded.inputs.map((input) => ({ ...input, limits: { ...input.limits, maxRecords: 1 } }))) : loaded;
243
347
  const runId = `run-${next++}`;
244
- const { access, accessPlugins } = await resolveSiteAccess(params, runId, ask);
245
- const crawler = createCrawler({
246
- sink: params.out === void 0 ? streamingSink() : fileSink2(params.out, { append: params.append, browser: { executablePath: params.browser?.executablePath } }),
247
- hooks: clientHooks(params.hooks ?? [], runId, ask),
248
- captchaSolvers: clientCaptchaSolvers(params.captchaSolvers ?? [], runId, ask),
249
- access,
250
- accessPlugins,
251
- profilesDir: process.env.OPENCRAW_PROFILES,
252
- parallel: params.parallel,
253
- resume: params.resume,
254
- debug: params.dryRun,
255
- browser: { headless: params.browser?.headless ?? true, executablePath: params.browser?.executablePath, ignoreHTTPSErrors: params.browser?.ignoreHTTPSErrors },
256
- onEvent: (event) => {
257
- notify("event", { runId, event });
258
- if (event.type === "record:emit" && params.out === void 0) notify("record", { runId, recipeId: event.recipeId, key: event.key, data: event.data });
259
- }
260
- });
261
- running.set(runId, crawler);
262
- void crawl(runId, crawler, set);
348
+ const crawler = await crawlerFor(runId, params);
349
+ track(runId, crawler, async () => await crawler.run(set));
350
+ return runId;
351
+ },
352
+ startWork: async (params) => {
353
+ const set = await loadRecipes(params);
354
+ const runId = `run-${next++}`;
355
+ const crawler = await crawlerFor(runId, params);
356
+ const windows = params.windows;
357
+ track(runId, crawler, async () => await crawler.work(set, clientWorkSource(runId, ask, notify), { windows }));
263
358
  return runId;
264
359
  },
265
360
  cancel: async (runId) => {
@@ -400,7 +495,7 @@ function openLineChannel(input, output, handle) {
400
495
  }
401
496
 
402
497
  // src/server/serve-driver.use-case.ts
403
- var METHODS = ["initialize", "validate", "run", "cancel", "probe", "diff", "export", "shutdown"];
498
+ var METHODS = ["initialize", "validate", "run", "work", "cancel", "probe", "diff", "export", "shutdown"];
404
499
  var major = (version) => version.split(".", 1)[0];
405
500
  function parsed(schema, params) {
406
501
  const result = schema.safeParse(params ?? {});
@@ -423,6 +518,7 @@ async function serveDriver(input, output, identity) {
423
518
  if (!initialized) throw new RpcFailure(RPC_ERRORS.server, "call initialize first");
424
519
  if (method === "validate") return await validateRecipes(parsed(validateParamsSchema, params));
425
520
  if (method === "run") return { runId: await sessions.start(parsed(runParamsSchema, params)) };
521
+ if (method === "work") return { runId: await sessions.startWork(parsed(workParamsSchema, params)) };
426
522
  if (method === "cancel") return { cancelled: await sessions.cancel(parsed(cancelParamsSchema, params).runId) };
427
523
  if (method === "probe") return await probeSite(parsed(probeParamsSchema, params), `probe-${++probes}`, channel.request);
428
524
  if (method === "diff") return await diffRecordFiles(parsed(diffParamsSchema, params));
@@ -434,7 +530,7 @@ async function serveDriver(input, output, identity) {
434
530
  return {};
435
531
  });
436
532
  const sessions = runSessions(channel.notify, channel.request);
437
- await Promise.race([channel.closed, once(stopping.signal, "abort")]);
533
+ await Promise.race([channel.closed, once2(stopping.signal, "abort")]);
438
534
  await channel.drained();
439
535
  await sessions.closeAll();
440
536
  }
@@ -1,6 +1,8 @@
1
1
  import type { AccessPlugin, CalloutTransport, CaptchaSolver, HookMap } from '@opencraw/core';
2
2
  /** Sends one request to the client and waits for its answer: the channel's `request`. */
3
3
  export type AskClient = (method: string, params: unknown) => Promise<unknown>;
4
+ /** Sends a notification to the client: the channel's `notify`. */
5
+ export type NotifyClient = (method: string, params: unknown) => void;
4
6
  /**
5
7
  * A transport whose handler is the client: each call is a `callout` request carrying core's own
6
8
  * `CalloutRequest`, and the client answers with a `CalloutResponse`, so `pending` and idempotency keys
@@ -1,3 +1,3 @@
1
1
  export { clientAccessPlugins, clientCaptchaSolvers, clientHooks, clientTransport } from './client-hooks.client.js';
2
- export type { AskClient } from './client-hooks.client.js';
2
+ export type { AskClient, NotifyClient } from './client-hooks.client.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,12 +1,14 @@
1
1
  export { serveDriver } from './server/index.js';
2
2
  export type { DriverIdentity } from './server/index.js';
3
- export { PROTOCOL_VERSION, MESSAGES, driverProtocolJsonSchema, initializeParamsSchema, initializeResultSchema, validateParamsSchema, validateResultSchema, runParamsSchema, runResultSchema, cancelParamsSchema, cancelResultSchema, eventParamsSchema, recordParamsSchema, finishedParamsSchema, calloutParamsSchema, calloutResultSchema, probeParamsSchema, probeResultSchema, diffParamsSchema, diffResultSchema, exportParamsSchema, exportResultSchema, } from './protocol/index.js';
4
- export type { InitializeParams, InitializeResult, ValidateParams, ValidateResult, RunParams, RunResult, CancelParams, CancelResult, EventParams, RecordParams, FinishedParams, CalloutParams, ProbeParams, DiffParams, DiffResult, ExportParams, ExportResult, } from './protocol/index.js';
3
+ export { PROTOCOL_VERSION, MESSAGES, driverProtocolJsonSchema, initializeParamsSchema, initializeResultSchema, validateParamsSchema, validateResultSchema, runParamsSchema, runResultSchema, cancelParamsSchema, cancelResultSchema, eventParamsSchema, recordParamsSchema, finishedParamsSchema, calloutParamsSchema, calloutResultSchema, probeParamsSchema, probeResultSchema, diffParamsSchema, diffResultSchema, exportParamsSchema, exportResultSchema, workParamsSchema, workItemSchema, workNextParamsSchema, workNextResultSchema, workDoneParamsSchema, workFailedParamsSchema, } from './protocol/index.js';
4
+ export type { InitializeParams, InitializeResult, ValidateParams, ValidateResult, RunParams, RunResult, CancelParams, CancelResult, EventParams, RecordParams, FinishedParams, CalloutParams, ProbeParams, DiffParams, DiffResult, ExportParams, ExportResult, WorkParams, WorkItemMessage, WorkNextResult, WorkDoneParams, WorkFailedParams, } from './protocol/index.js';
5
5
  export { openLineChannel, RPC_ERRORS, RpcFailure } from './transport/index.js';
6
6
  export type { LineChannel, RequestHandler, RpcError, RpcMessage } from './transport/index.js';
7
7
  export { runSessions, validateRecipes, streamingSink } from './session/index.js';
8
- export type { RunSessions, NotifyClient } from './session/index.js';
8
+ export type { RunSessions } from './session/index.js';
9
9
  export { clientHooks, clientCaptchaSolvers, clientAccessPlugins } from './callout-bridge/index.js';
10
+ export type { AskClient, NotifyClient } from './callout-bridge/index.js';
11
+ export { clientWorkSource } from './work-bridge/index.js';
10
12
  export { probeSite } from './site-probe/index.js';
11
13
  export { diffRecordFiles, exportRecordFiles } from './record-files/index.js';
12
14
  //# sourceMappingURL=index.d.ts.map
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  * The driver protocol's version, separate from the package's: a client pins a major and the driver refuses
4
4
  * another. Additions (methods, optional fields) raise the minor; anything a client could break on, the major.
5
5
  */
6
- export declare const PROTOCOL_VERSION = "1.1.0";
6
+ export declare const PROTOCOL_VERSION = "1.2.0";
7
7
  export declare const initializeParamsSchema: z.ZodObject<{
8
8
  clientName: z.ZodString;
9
9
  clientVersion: z.ZodString;
@@ -58,6 +58,31 @@ export type RunParams = z.infer<typeof runParamsSchema>;
58
58
  export declare const runResultSchema: z.ZodObject<{
59
59
  runId: z.ZodString;
60
60
  }, z.core.$strict>;
61
+ export declare const workParamsSchema: z.ZodObject<{
62
+ accessConfig: z.ZodOptional<z.ZodString>;
63
+ access: z.ZodOptional<z.ZodString>;
64
+ accessPlugins: z.ZodOptional<z.ZodArray<z.ZodString>>;
65
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
66
+ recipes: z.ZodOptional<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
67
+ only: z.ZodOptional<z.ZodArray<z.ZodString>>;
68
+ out: z.ZodOptional<z.ZodString>;
69
+ append: z.ZodOptional<z.ZodBoolean>;
70
+ browser: z.ZodOptional<z.ZodObject<{
71
+ headless: z.ZodOptional<z.ZodBoolean>;
72
+ executablePath: z.ZodOptional<z.ZodString>;
73
+ ignoreHTTPSErrors: z.ZodOptional<z.ZodBoolean>;
74
+ }, z.core.$strict>>;
75
+ hooks: z.ZodOptional<z.ZodArray<z.ZodString>>;
76
+ captchaSolvers: z.ZodOptional<z.ZodArray<z.ZodString>>;
77
+ windows: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>;
78
+ }, z.core.$strict>;
79
+ export type WorkParams = z.infer<typeof workParamsSchema>;
80
+ export declare const workItemSchema: z.ZodObject<{
81
+ id: z.ZodString;
82
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
83
+ recipe: z.ZodOptional<z.ZodString>;
84
+ }, z.core.$strict>;
85
+ export type WorkItemMessage = z.infer<typeof workItemSchema>;
61
86
  export type RunResult = z.infer<typeof runResultSchema>;
62
87
  export declare const cancelParamsSchema: z.ZodObject<{
63
88
  runId: z.ZodString;
@@ -114,6 +139,48 @@ export declare const exportResultSchema: z.ZodObject<{
114
139
  }, z.core.$strict>;
115
140
  export type ExportResult = z.infer<typeof exportResultSchema>;
116
141
  export declare const shutdownResultSchema: z.ZodObject<{}, z.core.$strict>;
142
+ /** A window wants its next item. The client may hold the answer until it has one; `null` ends the work. */
143
+ export declare const workNextParamsSchema: z.ZodObject<{
144
+ runId: z.ZodString;
145
+ }, z.core.$strict>;
146
+ export declare const workNextResultSchema: z.ZodObject<{
147
+ item: z.ZodNullable<z.ZodObject<{
148
+ id: z.ZodString;
149
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
150
+ recipe: z.ZodOptional<z.ZodString>;
151
+ }, z.core.$strict>>;
152
+ }, z.core.$strict>;
153
+ export type WorkNextResult = z.infer<typeof workNextResultSchema>;
154
+ /** An item succeeded: its report (`RecipeReport`) and its records. */
155
+ export declare const workDoneParamsSchema: z.ZodObject<{
156
+ runId: z.ZodString;
157
+ item: z.ZodObject<{
158
+ id: z.ZodString;
159
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
160
+ recipe: z.ZodOptional<z.ZodString>;
161
+ }, z.core.$strict>;
162
+ report: z.ZodRecord<z.ZodString, z.ZodUnknown>;
163
+ records: z.ZodArray<z.ZodObject<{
164
+ key: z.ZodNullable<z.ZodString>;
165
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
166
+ }, z.core.$strict>>;
167
+ }, z.core.$strict>;
168
+ export type WorkDoneParams = z.infer<typeof workDoneParamsSchema>;
169
+ /** An item failed (`failure`) or came out neither way (`neutral`, such as a captcha it could not pass). */
170
+ export declare const workFailedParamsSchema: z.ZodObject<{
171
+ runId: z.ZodString;
172
+ item: z.ZodObject<{
173
+ id: z.ZodString;
174
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
175
+ recipe: z.ZodOptional<z.ZodString>;
176
+ }, z.core.$strict>;
177
+ report: z.ZodRecord<z.ZodString, z.ZodUnknown>;
178
+ outcome: z.ZodEnum<{
179
+ failure: "failure";
180
+ neutral: "neutral";
181
+ }>;
182
+ }, z.core.$strict>;
183
+ export type WorkFailedParams = z.infer<typeof workFailedParamsSchema>;
117
184
  /** Every crawl event, as core reports it (`CrawlEvent`); its shape is core's, versioned with core. */
118
185
  export declare const eventParamsSchema: z.ZodObject<{
119
186
  runId: z.ZodString;
@@ -226,6 +293,30 @@ export declare const MESSAGES: {
226
293
  runId: z.ZodString;
227
294
  }, z.core.$strict>;
228
295
  };
296
+ readonly work: {
297
+ readonly direction: 'client-to-driver';
298
+ readonly params: z.ZodObject<{
299
+ accessConfig: z.ZodOptional<z.ZodString>;
300
+ access: z.ZodOptional<z.ZodString>;
301
+ accessPlugins: z.ZodOptional<z.ZodArray<z.ZodString>>;
302
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
303
+ recipes: z.ZodOptional<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
304
+ only: z.ZodOptional<z.ZodArray<z.ZodString>>;
305
+ out: z.ZodOptional<z.ZodString>;
306
+ append: z.ZodOptional<z.ZodBoolean>;
307
+ browser: z.ZodOptional<z.ZodObject<{
308
+ headless: z.ZodOptional<z.ZodBoolean>;
309
+ executablePath: z.ZodOptional<z.ZodString>;
310
+ ignoreHTTPSErrors: z.ZodOptional<z.ZodBoolean>;
311
+ }, z.core.$strict>>;
312
+ hooks: z.ZodOptional<z.ZodArray<z.ZodString>>;
313
+ captchaSolvers: z.ZodOptional<z.ZodArray<z.ZodString>>;
314
+ windows: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>;
315
+ }, z.core.$strict>;
316
+ readonly result: z.ZodObject<{
317
+ runId: z.ZodString;
318
+ }, z.core.$strict>;
319
+ };
229
320
  readonly cancel: {
230
321
  readonly direction: 'client-to-driver';
231
322
  readonly params: z.ZodObject<{
@@ -323,6 +414,53 @@ export declare const MESSAGES: {
323
414
  retryAfterMs: z.ZodOptional<z.ZodNumber>;
324
415
  }, z.core.$strict>], "status">;
325
416
  };
417
+ readonly 'work.next': {
418
+ readonly direction: 'driver-to-client';
419
+ readonly params: z.ZodObject<{
420
+ runId: z.ZodString;
421
+ }, z.core.$strict>;
422
+ readonly result: z.ZodObject<{
423
+ item: z.ZodNullable<z.ZodObject<{
424
+ id: z.ZodString;
425
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
426
+ recipe: z.ZodOptional<z.ZodString>;
427
+ }, z.core.$strict>>;
428
+ }, z.core.$strict>;
429
+ };
430
+ readonly 'work.done': {
431
+ readonly direction: 'driver-to-client';
432
+ readonly params: z.ZodObject<{
433
+ runId: z.ZodString;
434
+ item: z.ZodObject<{
435
+ id: z.ZodString;
436
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
437
+ recipe: z.ZodOptional<z.ZodString>;
438
+ }, z.core.$strict>;
439
+ report: z.ZodRecord<z.ZodString, z.ZodUnknown>;
440
+ records: z.ZodArray<z.ZodObject<{
441
+ key: z.ZodNullable<z.ZodString>;
442
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
443
+ }, z.core.$strict>>;
444
+ }, z.core.$strict>;
445
+ readonly result: undefined;
446
+ };
447
+ readonly 'work.failed': {
448
+ readonly direction: 'driver-to-client';
449
+ readonly params: z.ZodObject<{
450
+ runId: z.ZodString;
451
+ item: z.ZodObject<{
452
+ id: z.ZodString;
453
+ vars: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
454
+ recipe: z.ZodOptional<z.ZodString>;
455
+ }, z.core.$strict>;
456
+ report: z.ZodRecord<z.ZodString, z.ZodUnknown>;
457
+ outcome: z.ZodEnum<{
458
+ failure: "failure";
459
+ neutral: "neutral";
460
+ }>;
461
+ }, z.core.$strict>;
462
+ readonly result: undefined;
463
+ };
326
464
  readonly event: {
327
465
  readonly direction: 'driver-to-client';
328
466
  readonly params: z.ZodObject<{
@@ -1,4 +1,4 @@
1
- export { PROTOCOL_VERSION, MESSAGES, initializeParamsSchema, initializeResultSchema, validateParamsSchema, validateResultSchema, runParamsSchema, runResultSchema, cancelParamsSchema, cancelResultSchema, shutdownResultSchema, eventParamsSchema, recordParamsSchema, finishedParamsSchema, calloutParamsSchema, calloutResultSchema, probeParamsSchema, probeResultSchema, diffParamsSchema, diffResultSchema, exportParamsSchema, exportResultSchema, } from './driver-message.contract.js';
2
- export type { InitializeParams, InitializeResult, ValidateParams, ValidateResult, RunParams, RunResult, CancelParams, CancelResult, EventParams, RecordParams, FinishedParams, CalloutParams, ProbeParams, DiffParams, DiffResult, ExportParams, ExportResult, } from './driver-message.contract.js';
1
+ export { PROTOCOL_VERSION, MESSAGES, initializeParamsSchema, initializeResultSchema, validateParamsSchema, validateResultSchema, runParamsSchema, runResultSchema, cancelParamsSchema, cancelResultSchema, shutdownResultSchema, eventParamsSchema, recordParamsSchema, finishedParamsSchema, calloutParamsSchema, calloutResultSchema, probeParamsSchema, probeResultSchema, diffParamsSchema, diffResultSchema, exportParamsSchema, exportResultSchema, workParamsSchema, workItemSchema, workNextParamsSchema, workNextResultSchema, workDoneParamsSchema, workFailedParamsSchema, } from './driver-message.contract.js';
2
+ export type { InitializeParams, InitializeResult, ValidateParams, ValidateResult, RunParams, RunResult, CancelParams, CancelResult, EventParams, RecordParams, FinishedParams, CalloutParams, ProbeParams, DiffParams, DiffResult, ExportParams, ExportResult, WorkParams, WorkItemMessage, WorkNextResult, WorkDoneParams, WorkFailedParams, } from './driver-message.contract.js';
3
3
  export { driverProtocolJsonSchema } from './driver-json-schema.mapper.js';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1,5 +1,5 @@
1
1
  export { runSessions } from './run-session.use-case.js';
2
- export type { NotifyClient, RunSessions } from './run-session.use-case.js';
2
+ export type { RunSessions } from './run-session.use-case.js';
3
3
  export { validateRecipes, hooksOf, loadRecipes, sourceOf } from './recipe-loading.use-case.js';
4
4
  export { streamingSink } from './streaming-sink.repository.js';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1,23 +1,23 @@
1
- import type { AskClient } from '../callout-bridge/index.js';
2
- import type { RunParams } from '../protocol/index.js';
3
- /** Sends a notification to the client. */
4
- export type NotifyClient = (method: string, params: unknown) => void;
1
+ import type { AskClient, NotifyClient } from '../callout-bridge/index.js';
2
+ import type { RunParams, WorkParams } from '../protocol/index.js';
5
3
  /** The runs a driver has going, by id, so `cancel` can reach them and `shutdown` close them all. */
6
4
  export interface RunSessions {
7
5
  start: (params: RunParams) => Promise<string>;
6
+ startWork: (params: WorkParams) => Promise<string>;
8
7
  cancel: (runId: string) => Promise<boolean>;
9
8
  closeAll: () => Promise<void>;
10
9
  }
11
10
  /**
12
- * Starts and tracks runs. `start` loads and binds the recipes (a bad recipe fails the `run` request
13
- * itself, with its problems), answers with a run id at once, and crawls in the background: every crawl
14
- * event goes to the client as an `event` notification, every record as a `record` one unless `out`
15
- * names a file, and the end as `finished` with the report. A hook the client serves is asked back with
16
- * a `callout` request, and so are the captcha solvers and access plugins it serves. `cancel` closes the run's crawler, which stops its recipes; the run still ends
17
- * with `finished`.
11
+ * Starts and tracks runs and worker pools. `start` and `startWork` load and bind the recipes (a bad
12
+ * recipe fails the request itself, with its problems), answer with a run id at once, and crawl in the
13
+ * background: every crawl event goes to the client as an `event` notification, every record as a
14
+ * `record` one unless `out` names a file, and the end as `finished` with the report. A hook the client
15
+ * serves is asked back with a `callout` request, and so are the captcha solvers and access plugins it
16
+ * serves; a worker pool asks the client for its items (`work.next`). `cancel` closes the run's crawler,
17
+ * which stops its recipes; the run still ends with `finished`.
18
18
  *
19
19
  * @param notify - The channel's notify.
20
- * @param ask - The channel's request, for callouts.
20
+ * @param ask - The channel's request, for callouts and work.
21
21
  * @returns The sessions.
22
22
  */
23
23
  export declare function runSessions(notify: NotifyClient, ask: AskClient): RunSessions;
@@ -0,0 +1,17 @@
1
+ import type { WorkSource } from '@opencraw/core';
2
+ import type { AskClient, NotifyClient } from '../callout-bridge/index.js';
3
+ /**
4
+ * A `WorkSource` whose items come from the client: each window that wants work sends a `work.next`
5
+ * request, which the client may hold until it has an item, and an answer of `null` ends the work. Each
6
+ * item's end goes back as `work.done` (with its records) or `work.failed`.
7
+ *
8
+ * A window that retires while its request is out (core aborts its `next`) still gets an answer later;
9
+ * that item is kept and handed to the next window that asks, so nothing the client sent is lost.
10
+ *
11
+ * @param runId - The work's run id, sent with every message.
12
+ * @param ask - The channel's request.
13
+ * @param notify - The channel's notify.
14
+ * @returns The source for `crawler.work`.
15
+ */
16
+ export declare function clientWorkSource(runId: string, ask: AskClient, notify: NotifyClient): WorkSource;
17
+ //# sourceMappingURL=client-work-source.client.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { clientWorkSource } from './client-work-source.client.js';
2
+ //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencraw/driver",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCraw's driver: the engine over JSON-RPC on stdio, for clients in other languages",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,8 +40,8 @@
40
40
  "schemas": "node tools/emit-protocol-schema.mjs"
41
41
  },
42
42
  "dependencies": {
43
- "@opencraw/cli": "^0.1.56",
44
- "@opencraw/core": "^0.3.0",
43
+ "@opencraw/cli": "^0.0.1",
44
+ "@opencraw/core": "^0.0.1",
45
45
  "zod": "^4.6.5"
46
46
  }
47
47
  }
@@ -3,7 +3,7 @@
3
3
  "$id": "https://opencraw/schemas/driver-protocol.schema.json",
4
4
  "title": "OpenCraw driver protocol",
5
5
  "description": "JSON-RPC 2.0 over stdio, one message per line. Methods with a result are requests; the rest are notifications.",
6
- "protocolVersion": "1.1.0",
6
+ "protocolVersion": "1.2.0",
7
7
  "methods": {
8
8
  "initialize": {
9
9
  "direction": "client-to-driver",
@@ -32,6 +32,15 @@
32
32
  "$ref": "#/$defs/run.result"
33
33
  }
34
34
  },
35
+ "work": {
36
+ "direction": "client-to-driver",
37
+ "params": {
38
+ "$ref": "#/$defs/work.params"
39
+ },
40
+ "result": {
41
+ "$ref": "#/$defs/work.result"
42
+ }
43
+ },
35
44
  "cancel": {
36
45
  "direction": "client-to-driver",
37
46
  "params": {
@@ -83,6 +92,27 @@
83
92
  "$ref": "#/$defs/callout.result"
84
93
  }
85
94
  },
95
+ "work.next": {
96
+ "direction": "driver-to-client",
97
+ "params": {
98
+ "$ref": "#/$defs/work.next.params"
99
+ },
100
+ "result": {
101
+ "$ref": "#/$defs/work.next.result"
102
+ }
103
+ },
104
+ "work.done": {
105
+ "direction": "driver-to-client",
106
+ "params": {
107
+ "$ref": "#/$defs/work.done.params"
108
+ }
109
+ },
110
+ "work.failed": {
111
+ "direction": "driver-to-client",
112
+ "params": {
113
+ "$ref": "#/$defs/work.failed.params"
114
+ }
115
+ },
86
116
  "event": {
87
117
  "direction": "driver-to-client",
88
118
  "params": {
@@ -335,6 +365,111 @@
335
365
  ],
336
366
  "additionalProperties": false
337
367
  },
368
+ "work.params": {
369
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
370
+ "type": "object",
371
+ "properties": {
372
+ "paths": {
373
+ "minItems": 1,
374
+ "type": "array",
375
+ "items": {
376
+ "type": "string"
377
+ }
378
+ },
379
+ "recipes": {
380
+ "minItems": 1,
381
+ "type": "array",
382
+ "items": {
383
+ "type": "object",
384
+ "propertyNames": {
385
+ "type": "string"
386
+ },
387
+ "additionalProperties": {}
388
+ }
389
+ },
390
+ "only": {
391
+ "type": "array",
392
+ "items": {
393
+ "type": "string"
394
+ }
395
+ },
396
+ "out": {
397
+ "type": "string"
398
+ },
399
+ "append": {
400
+ "type": "boolean"
401
+ },
402
+ "browser": {
403
+ "type": "object",
404
+ "properties": {
405
+ "headless": {
406
+ "type": "boolean"
407
+ },
408
+ "executablePath": {
409
+ "type": "string"
410
+ },
411
+ "ignoreHTTPSErrors": {
412
+ "type": "boolean"
413
+ }
414
+ },
415
+ "additionalProperties": false
416
+ },
417
+ "hooks": {
418
+ "type": "array",
419
+ "items": {
420
+ "type": "string"
421
+ }
422
+ },
423
+ "captchaSolvers": {
424
+ "type": "array",
425
+ "items": {
426
+ "type": "string"
427
+ }
428
+ },
429
+ "accessConfig": {
430
+ "type": "string"
431
+ },
432
+ "access": {
433
+ "type": "string"
434
+ },
435
+ "accessPlugins": {
436
+ "type": "array",
437
+ "items": {
438
+ "type": "string"
439
+ }
440
+ },
441
+ "windows": {
442
+ "anyOf": [
443
+ {
444
+ "type": "integer",
445
+ "minimum": 1,
446
+ "maximum": 64
447
+ },
448
+ {
449
+ "type": "object",
450
+ "propertyNames": {
451
+ "type": "string"
452
+ },
453
+ "additionalProperties": {}
454
+ }
455
+ ]
456
+ }
457
+ },
458
+ "additionalProperties": false
459
+ },
460
+ "work.result": {
461
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
462
+ "type": "object",
463
+ "properties": {
464
+ "runId": {
465
+ "type": "string"
466
+ }
467
+ },
468
+ "required": [
469
+ "runId"
470
+ ],
471
+ "additionalProperties": false
472
+ },
338
473
  "cancel.params": {
339
474
  "$schema": "https://json-schema.org/draft/2020-12/schema",
340
475
  "type": "object",
@@ -690,6 +825,205 @@
690
825
  }
691
826
  ]
692
827
  },
828
+ "work.next.params": {
829
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
830
+ "type": "object",
831
+ "properties": {
832
+ "runId": {
833
+ "type": "string"
834
+ }
835
+ },
836
+ "required": [
837
+ "runId"
838
+ ],
839
+ "additionalProperties": false
840
+ },
841
+ "work.next.result": {
842
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
843
+ "type": "object",
844
+ "properties": {
845
+ "item": {
846
+ "anyOf": [
847
+ {
848
+ "type": "object",
849
+ "properties": {
850
+ "id": {
851
+ "type": "string",
852
+ "minLength": 1
853
+ },
854
+ "vars": {
855
+ "type": "object",
856
+ "propertyNames": {
857
+ "type": "string"
858
+ },
859
+ "additionalProperties": {
860
+ "type": [
861
+ "string",
862
+ "number",
863
+ "boolean"
864
+ ]
865
+ }
866
+ },
867
+ "recipe": {
868
+ "type": "string"
869
+ }
870
+ },
871
+ "required": [
872
+ "id",
873
+ "vars"
874
+ ],
875
+ "additionalProperties": false
876
+ },
877
+ {
878
+ "type": "null"
879
+ }
880
+ ]
881
+ }
882
+ },
883
+ "required": [
884
+ "item"
885
+ ],
886
+ "additionalProperties": false
887
+ },
888
+ "work.done.params": {
889
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
890
+ "type": "object",
891
+ "properties": {
892
+ "runId": {
893
+ "type": "string"
894
+ },
895
+ "item": {
896
+ "type": "object",
897
+ "properties": {
898
+ "id": {
899
+ "type": "string",
900
+ "minLength": 1
901
+ },
902
+ "vars": {
903
+ "type": "object",
904
+ "propertyNames": {
905
+ "type": "string"
906
+ },
907
+ "additionalProperties": {
908
+ "type": [
909
+ "string",
910
+ "number",
911
+ "boolean"
912
+ ]
913
+ }
914
+ },
915
+ "recipe": {
916
+ "type": "string"
917
+ }
918
+ },
919
+ "required": [
920
+ "id",
921
+ "vars"
922
+ ],
923
+ "additionalProperties": false
924
+ },
925
+ "report": {
926
+ "type": "object",
927
+ "propertyNames": {
928
+ "type": "string"
929
+ },
930
+ "additionalProperties": {}
931
+ },
932
+ "records": {
933
+ "type": "array",
934
+ "items": {
935
+ "type": "object",
936
+ "properties": {
937
+ "key": {
938
+ "type": [
939
+ "string",
940
+ "null"
941
+ ]
942
+ },
943
+ "data": {
944
+ "type": "object",
945
+ "propertyNames": {
946
+ "type": "string"
947
+ },
948
+ "additionalProperties": {}
949
+ }
950
+ },
951
+ "required": [
952
+ "key",
953
+ "data"
954
+ ],
955
+ "additionalProperties": false
956
+ }
957
+ }
958
+ },
959
+ "required": [
960
+ "runId",
961
+ "item",
962
+ "report",
963
+ "records"
964
+ ],
965
+ "additionalProperties": false
966
+ },
967
+ "work.failed.params": {
968
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
969
+ "type": "object",
970
+ "properties": {
971
+ "runId": {
972
+ "type": "string"
973
+ },
974
+ "item": {
975
+ "type": "object",
976
+ "properties": {
977
+ "id": {
978
+ "type": "string",
979
+ "minLength": 1
980
+ },
981
+ "vars": {
982
+ "type": "object",
983
+ "propertyNames": {
984
+ "type": "string"
985
+ },
986
+ "additionalProperties": {
987
+ "type": [
988
+ "string",
989
+ "number",
990
+ "boolean"
991
+ ]
992
+ }
993
+ },
994
+ "recipe": {
995
+ "type": "string"
996
+ }
997
+ },
998
+ "required": [
999
+ "id",
1000
+ "vars"
1001
+ ],
1002
+ "additionalProperties": false
1003
+ },
1004
+ "report": {
1005
+ "type": "object",
1006
+ "propertyNames": {
1007
+ "type": "string"
1008
+ },
1009
+ "additionalProperties": {}
1010
+ },
1011
+ "outcome": {
1012
+ "type": "string",
1013
+ "enum": [
1014
+ "failure",
1015
+ "neutral"
1016
+ ]
1017
+ }
1018
+ },
1019
+ "required": [
1020
+ "runId",
1021
+ "item",
1022
+ "report",
1023
+ "outcome"
1024
+ ],
1025
+ "additionalProperties": false
1026
+ },
693
1027
  "event.params": {
694
1028
  "$schema": "https://json-schema.org/draft/2020-12/schema",
695
1029
  "type": "object",