@opencraw/azure-durable 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +125 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.esm.js +1451 -0
  4. package/dist/src/caller-access/caller-access.policy.d.ts +28 -0
  5. package/dist/src/caller-access/index.d.ts +3 -0
  6. package/dist/src/crawl-run/crawl-job.model.d.ts +40 -0
  7. package/dist/src/crawl-run/crawl-job.validator.d.ts +11 -0
  8. package/dist/src/crawl-run/crawl-orchestration.use-case.d.ts +12 -0
  9. package/dist/src/crawl-run/crawl-request.validator.d.ts +25 -0
  10. package/dist/src/crawl-run/index.d.ts +8 -0
  11. package/dist/src/crawl-run/run-recipe.use-case.d.ts +13 -0
  12. package/dist/src/crawl-run/start-crawl.handler.d.ts +18 -0
  13. package/dist/src/host-options/host-options.config.d.ts +85 -0
  14. package/dist/src/host-options/index.d.ts +3 -0
  15. package/dist/src/http-reply/http-reply.mapper.d.ts +24 -0
  16. package/dist/src/http-reply/index.d.ts +2 -0
  17. package/dist/src/index.d.ts +10 -0
  18. package/dist/src/open-craw-host/host-json.validator.d.ts +11 -0
  19. package/dist/src/open-craw-host/index.d.ts +4 -0
  20. package/dist/src/open-craw-host/register-open-craw.use-case.d.ts +45 -0
  21. package/dist/src/recipe-authoring/authoring-server.use-case.d.ts +13 -0
  22. package/dist/src/recipe-authoring/authoring-tools.use-case.d.ts +96 -0
  23. package/dist/src/recipe-authoring/index.d.ts +5 -0
  24. package/dist/src/recipe-authoring/mcp-endpoint.handler.d.ts +18 -0
  25. package/dist/src/recipe-authoring/promote.handler.d.ts +13 -0
  26. package/dist/src/recipe-store/blob-recipe.store.d.ts +20 -0
  27. package/dist/src/recipe-store/find-recipes.use-case.d.ts +13 -0
  28. package/dist/src/recipe-store/index.d.ts +7 -0
  29. package/dist/src/recipe-store/memory-recipe.store.d.ts +12 -0
  30. package/dist/src/recipe-store/recipe-store.contract.d.ts +28 -0
  31. package/dist/src/recipe-store/recipe-store.error.d.ts +6 -0
  32. package/dist/src/result-store/blob-result.store.d.ts +18 -0
  33. package/dist/src/result-store/index.d.ts +6 -0
  34. package/dist/src/result-store/result-packing.policy.d.ts +22 -0
  35. package/dist/src/result-store/result-store.contract.d.ts +13 -0
  36. package/dist/src/worker-pools/index.d.ts +10 -0
  37. package/dist/src/worker-pools/item-job.validator.d.ts +10 -0
  38. package/dist/src/worker-pools/item-orchestration.use-case.d.ts +14 -0
  39. package/dist/src/worker-pools/item-request.validator.d.ts +35 -0
  40. package/dist/src/worker-pools/job.handler.d.ts +14 -0
  41. package/dist/src/worker-pools/pool-job.model.d.ts +43 -0
  42. package/dist/src/worker-pools/submit-item.handler.d.ts +20 -0
  43. package/dist/src/worker-pools/windows-cap.policy.d.ts +12 -0
  44. package/dist/src/worker-pools/worker-pools.store.d.ts +58 -0
  45. package/package.json +93 -0
@@ -0,0 +1,1451 @@
1
+ import { app } from '@azure/functions';
2
+ import { activityTask, any, resultOf, startOrchestration, callActivity, defineOrchestration, defineActivity } from '@mnci/az-durable';
3
+ import * as df from 'durable-functions';
4
+ import { memorySink, createCrawler, loadRecipes, createWorkInbox } from '@opencraw/core';
5
+ import { BlobServiceClient, BlobSASPermissions, RestError } from '@azure/storage-blob';
6
+ import { z } from 'zod';
7
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { setTimeout } from 'node:timers/promises';
10
+ import { probeUrl } from '@opencraw/cli';
11
+ import { validateTool } from '@opencraw/mcp';
12
+ import { createHash } from 'node:crypto';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ /**
17
+ * The `/crawl` orchestration: one activity per input recipe, all at once;
18
+ * the custom status counts recipes and records as each one finishes.
19
+ *
20
+ * @param runRecipe - The activity that runs one recipe.
21
+ * @returns The orchestrator body.
22
+ */
23
+ function crawlOrchestrator(runRecipe) {
24
+ return function* crawl(context, job) {
25
+ const tasks = job.inputs.map((input, index) => activityTask(context, runRecipe, {
26
+ allowedHosts: job.allowedHosts,
27
+ output: job.output,
28
+ input,
29
+ resultName: `${context.df.instanceId}/${index}.jsonl`,
30
+ ...(job.dedupe !== undefined && {
31
+ dedupe: job.dedupe
32
+ })
33
+ }));
34
+ const finished = new Map();
35
+ const progress = {
36
+ recipes: tasks.length,
37
+ recipesDone: 0,
38
+ records: 0
39
+ };
40
+ context.df.setCustomStatus(progress);
41
+ let running = tasks;
42
+ while (running.length > 0) {
43
+ const winner = yield* any(context, running);
44
+ const result = resultOf(winner);
45
+ finished.set(winner, result);
46
+ running = running.filter(task => task !== winner);
47
+ progress.recipesDone += 1;
48
+ progress.records += result.reports.reduce((sum, report) => sum + report.emitted, 0);
49
+ context.df.setCustomStatus(progress);
50
+ }
51
+ const results = tasks.map(task => finished.get(task)).filter(result => result !== undefined);
52
+ return {
53
+ recipes: results.flatMap(result => result.reports),
54
+ records: results.flatMap(result => result.records ?? []),
55
+ results: results.flatMap(result => result.resultUrl === undefined ? [] : [result.resultUrl])
56
+ };
57
+ };
58
+ }
59
+
60
+ const KIB = 1024;
61
+ const MINUTE_MS = 60_000;
62
+ /**
63
+ * @param options - As given.
64
+ * @returns The options with their defaults.
65
+ * @throws Error when `allowedHosts` is an empty list (a host that may reach nothing runs nothing), or the MCP endpoint would be open to anyone.
66
+ */
67
+ function resolveHostOptions(options) {
68
+ if (Array.isArray(options.allowedHosts) && options.allowedHosts.length === 0) throw new Error('allowedHosts is empty: list the hosts recipes may reach, or "*" for any');
69
+ const authLevel = options.authLevel ?? 'function';
70
+ const mcp = options.mcp === undefined || options.mcp === false ? undefined : {
71
+ sampleRecords: 20,
72
+ sampleMs: MINUTE_MS,
73
+ ...(options.mcp !== true && options.mcp)
74
+ };
75
+ if (mcp !== undefined && authLevel === 'anonymous' && options.identify === undefined) throw new Error('the MCP endpoint needs authentication: keep authLevel "function", or put the app behind App Service authentication and set identify');
76
+ const {
77
+ mcp: _mcp,
78
+ canPromote,
79
+ ...rest
80
+ } = options;
81
+ return {
82
+ ...rest,
83
+ ...(mcp !== undefined && {
84
+ mcp
85
+ }),
86
+ canPromote: canPromote ?? (() => false),
87
+ inlineLimitBytes: options.inlineLimitBytes ?? 256 * KIB,
88
+ routePrefix: (options.routePrefix ?? '').replaceAll(/^\/+|\/+$/g, ''),
89
+ authLevel,
90
+ pools: {
91
+ idleTtlMs: options.pools?.idleTtlMs ?? 10 * MINUTE_MS,
92
+ windows: options.pools?.windows ?? {
93
+ min: 1,
94
+ max: 4,
95
+ grow: {
96
+ after: 5
97
+ },
98
+ idle: {
99
+ afterMs: MINUTE_MS
100
+ }
101
+ },
102
+ maxWindows: options.pools?.maxWindows ?? 8
103
+ }
104
+ };
105
+ }
106
+ /**
107
+ * What a crawler for one caller is created with: the host's code and
108
+ * secrets, and the caller's hosts.
109
+ *
110
+ * @param settings - The host settings.
111
+ * @param allowedHosts - The caller's hosts.
112
+ * @returns Crawl options, without a sink.
113
+ */
114
+ function crawlOptionsFor(settings, allowedHosts) {
115
+ return {
116
+ allowedHosts: [...allowedHosts],
117
+ hooks: settings.hooks,
118
+ captchaSolvers: settings.captchaSolvers?.(),
119
+ access: settings.access,
120
+ accessPlugins: settings.accessPlugins,
121
+ browser: settings.browser
122
+ };
123
+ }
124
+
125
+ const DAY_MS = 86_400_000;
126
+ /**
127
+ * Results in Azure Blob Storage, as JSON Lines, returned as read-only SAS
128
+ * links that expire.
129
+ *
130
+ * @param options - The account, container and link lifetime.
131
+ * @returns The store.
132
+ */
133
+ function blobResults(options) {
134
+ const container = BlobServiceClient.fromConnectionString(options.connectionString).getContainerClient(options.container ?? 'opencraw-results');
135
+ let ready;
136
+ return {
137
+ async save(name, records) {
138
+ ready ??= container.createIfNotExists();
139
+ await ready;
140
+ const blob = container.getBlockBlobClient(name);
141
+ const body = records.map(record => JSON.stringify(record)).join('\n');
142
+ await blob.upload(body, Buffer.byteLength(body), {
143
+ blobHTTPHeaders: {
144
+ blobContentType: 'application/x-ndjson'
145
+ }
146
+ });
147
+ return await blob.generateSasUrl({
148
+ permissions: BlobSASPermissions.parse('r'),
149
+ expiresOn: new Date(Date.now() + (options.linkTtlMs ?? DAY_MS))
150
+ });
151
+ }
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Returns records inline when small, else saves them and returns a link: an
157
+ * orchestration's history holds every activity output, so a large result
158
+ * belongs in storage, not in the task hub.
159
+ *
160
+ * @param records - The records.
161
+ * @param options - The store (without one, records are always inline), the limit, the name to save under.
162
+ * @returns The records or their link.
163
+ */
164
+ async function packRecords(records, options) {
165
+ if (options.store === undefined || Buffer.byteLength(JSON.stringify(records)) <= options.inlineLimitBytes) return {
166
+ records: [...records]
167
+ };
168
+ return {
169
+ resultUrl: await options.store.save(options.name, records)
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The `/crawl` activity: one input recipe, on a crawler of its own, limited
175
+ * to the caller's hosts. A recipe that fails is reported, not thrown: the
176
+ * orchestration still gathers the others.
177
+ *
178
+ * @param settings - The host settings.
179
+ * @param task - The recipe and where its records go.
180
+ * @returns Its reports (one per matrix variant) and records (or their link).
181
+ */
182
+ async function runRecipeTask(settings, task) {
183
+ const sink = memorySink();
184
+ const crawler = createCrawler({
185
+ ...crawlOptionsFor(settings, task.allowedHosts),
186
+ sink,
187
+ dedupe: task.dedupe ?? 'recipe'
188
+ });
189
+ let reports;
190
+ try {
191
+ const set = await loadRecipes([task.output, task.input]);
192
+ const result = await crawler.run(set);
193
+ reports = result.recipes;
194
+ } finally {
195
+ await crawler.close();
196
+ }
197
+ const packed = await packRecords(sink.records, {
198
+ store: settings.results,
199
+ inlineLimitBytes: settings.inlineLimitBytes,
200
+ name: task.resultName
201
+ });
202
+ return {
203
+ reports,
204
+ ...packed
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Resolves the caller of a request and the hosts its recipes may reach.
210
+ *
211
+ * @param request - The request.
212
+ * @param rules - The host's `identify` and `allowedHosts`.
213
+ * @returns The access, or `undefined` when this caller may run nothing.
214
+ */
215
+ function callerAccess(request, rules) {
216
+ const caller = rules.identify?.(request);
217
+ const allowedHosts = typeof rules.allowedHosts === 'function' ? rules.allowedHosts(caller) : rules.allowedHosts;
218
+ if (allowedHosts === undefined || allowedHosts.length === 0) return undefined;
219
+ return {
220
+ ...(caller !== undefined && {
221
+ caller
222
+ }),
223
+ allowedHosts
224
+ };
225
+ }
226
+ /**
227
+ * The key a caller's pools and jobs live under, so two callers never share a pool.
228
+ *
229
+ * @param access - The caller.
230
+ * @param crawlId - The job.
231
+ * @returns The key.
232
+ */
233
+ function poolKey(access, crawlId) {
234
+ return `${access.caller ?? ''}\u{0}${crawlId}`;
235
+ }
236
+
237
+ /**
238
+ * @param status - The HTTP status.
239
+ * @param body - A JSON body.
240
+ * @returns The response.
241
+ */
242
+ function reply(status, body) {
243
+ return {
244
+ status,
245
+ jsonBody: body
246
+ };
247
+ }
248
+ /**
249
+ * An error the caller can act on: what went wrong, and the issues a recipe
250
+ * error lists (each with its JSON path).
251
+ *
252
+ * @param status - The HTTP status.
253
+ * @param error - What went wrong.
254
+ * @returns The response.
255
+ */
256
+ function problem(status, error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ const issues = typeof error === 'object' && error !== null && 'issues' in error ? error.issues : undefined;
259
+ return reply(status, {
260
+ error: message,
261
+ ...(issues !== undefined && {
262
+ issues
263
+ })
264
+ });
265
+ }
266
+ /**
267
+ * The request body as JSON.
268
+ *
269
+ * @param request - The request.
270
+ * @returns The body, or `undefined` when it is not JSON.
271
+ */
272
+ async function readJson(request) {
273
+ try {
274
+ return await request.json();
275
+ } catch {
276
+ return undefined;
277
+ }
278
+ }
279
+
280
+ /** A version that does not exist, or a write to one that does. */
281
+ class RecipeStoreError extends Error {
282
+ status;
283
+ constructor(message, status) {
284
+ super(message);
285
+ this.status = status;
286
+ this.name = 'RecipeStoreError';
287
+ }
288
+ }
289
+
290
+ const keyOf = (name, version) => `${name}@${version}`;
291
+ /**
292
+ * Recipes held in memory: the ones a deployment ships with (promoted), plus
293
+ * drafts published while it runs, which a restart forgets.
294
+ *
295
+ * @param entries - The recipe sets; `state` defaults to `promoted`.
296
+ * @returns The store.
297
+ */
298
+ function memoryRecipes(entries = []) {
299
+ const byKey = new Map();
300
+ for (const entry of entries) byKey.set(keyOf(entry.name, entry.version), {
301
+ ...entry,
302
+ state: entry.state ?? 'promoted'
303
+ });
304
+ return {
305
+ get: async (name, version) => byKey.get(keyOf(name, version)),
306
+ list: async () => Array.from(byKey.values(), ({
307
+ recipes: _recipes,
308
+ ...entry
309
+ }) => entry),
310
+ put: async entry => {
311
+ const key = keyOf(entry.name, entry.version);
312
+ if (byKey.has(key)) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" exist already; publish a new version`, 409);
313
+ byKey.set(key, {
314
+ ...entry,
315
+ state: 'draft'
316
+ });
317
+ },
318
+ promote: async (name, version) => {
319
+ const entry = byKey.get(keyOf(name, version));
320
+ if (entry === undefined) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
321
+ byKey.set(keyOf(name, version), {
322
+ ...entry,
323
+ state: 'promoted'
324
+ });
325
+ }
326
+ };
327
+ }
328
+
329
+ /**
330
+ * A stored recipe set production may run.
331
+ *
332
+ * @param store - The store, if the host has one.
333
+ * @param name - The set's name.
334
+ * @param version - Its version.
335
+ * @param allowDraft - Whether a draft will do (authoring tools), or only a promoted version (production).
336
+ * @returns The set.
337
+ * @throws RecipeStoreError 404 when there is no such version, 409 when it is a draft and a draft won't do.
338
+ */
339
+ async function findRecipes(store, name, version, allowDraft = false) {
340
+ const found = await store?.get(name, version);
341
+ if (found === undefined) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
342
+ if (!allowDraft && found.state === 'draft') throw new RecipeStoreError(`recipes "${name}" version "${version}" are a draft: promote them before production runs them`, 409);
343
+ return found;
344
+ }
345
+
346
+ const blobName = (name, version) => `${encodeURIComponent(name)}/${encodeURIComponent(version)}.json`;
347
+ const missing = error => error instanceof RestError && error.statusCode === 404;
348
+ const taken = error => error instanceof RestError && (error.statusCode === 409 || error.statusCode === 412);
349
+ /**
350
+ * Named recipes in Azure Blob Storage: one JSON blob per version, its state
351
+ * (`draft` or `promoted`) in the blob's metadata. A version is written once:
352
+ * a second publish of it fails even when two race, since the write only
353
+ * succeeds when no blob is there.
354
+ *
355
+ * @param options - The account, the container, and the sets the deployment ships with.
356
+ * @returns The store.
357
+ */
358
+ function blobRecipes(options) {
359
+ const container = BlobServiceClient.fromConnectionString(options.connectionString).getContainerClient(options.container ?? 'opencraw-recipes');
360
+ const shipped = memoryRecipes(options.shipped ?? []);
361
+ let ready;
362
+ const blob = async (name, version) => {
363
+ ready ??= container.createIfNotExists();
364
+ await ready;
365
+ return container.getBlobClient(blobName(name, version));
366
+ };
367
+ return {
368
+ async get(name, version) {
369
+ const found = await shipped.get(name, version);
370
+ if (found !== undefined) return found;
371
+ const client = await blob(name, version);
372
+ try {
373
+ const [bytes, properties] = await Promise.all([client.downloadToBuffer(), client.getProperties()]);
374
+ return {
375
+ name,
376
+ version,
377
+ recipes: JSON.parse(bytes.toString('utf8')),
378
+ state: properties.metadata?.state === 'promoted' ? 'promoted' : 'draft'
379
+ };
380
+ } catch (error) {
381
+ if (missing(error)) return;
382
+ throw error;
383
+ }
384
+ },
385
+ async list() {
386
+ ready ??= container.createIfNotExists();
387
+ await ready;
388
+ const published = [];
389
+ const blobs = container.listBlobsFlat({
390
+ includeMetadata: true
391
+ });
392
+ for await (const item of blobs) {
393
+ const metadata = item.metadata;
394
+ if (metadata?.name !== undefined && metadata.version !== undefined) published.push({
395
+ name: metadata.name,
396
+ version: metadata.version,
397
+ state: metadata.state === 'promoted' ? 'promoted' : 'draft'
398
+ });
399
+ }
400
+ return [...(await shipped.list()), ...published];
401
+ },
402
+ async put(entry) {
403
+ if ((await shipped.get(entry.name, entry.version)) !== undefined) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" ship with the deployment; publish a new version`, 409);
404
+ const body = JSON.stringify(entry.recipes);
405
+ const metadata = {
406
+ name: entry.name,
407
+ version: entry.version,
408
+ state: 'draft'
409
+ };
410
+ const client = await blob(entry.name, entry.version);
411
+ try {
412
+ await client.getBlockBlobClient().upload(body, Buffer.byteLength(body), {
413
+ metadata,
414
+ conditions: {
415
+ ifNoneMatch: '*'
416
+ },
417
+ blobHTTPHeaders: {
418
+ blobContentType: 'application/json'
419
+ }
420
+ });
421
+ } catch (error) {
422
+ if (taken(error)) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" exist already; publish a new version`, 409);
423
+ throw error;
424
+ }
425
+ },
426
+ async promote(name, version) {
427
+ if ((await shipped.get(name, version)) !== undefined) return;
428
+ const client = await blob(name, version);
429
+ try {
430
+ const properties = await client.getProperties();
431
+ await client.setMetadata({
432
+ ...properties.metadata,
433
+ state: 'promoted'
434
+ }, {
435
+ conditions: {
436
+ ifMatch: properties.etag
437
+ }
438
+ });
439
+ } catch (error) {
440
+ if (missing(error)) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
441
+ throw error;
442
+ }
443
+ }
444
+ };
445
+ }
446
+
447
+ const recipeObject = z.record(z.string(), z.unknown());
448
+ const options = z.strictObject({
449
+ /** `recipe` (default) or `off`: each input recipe runs in its own activity, so nothing spans them. */
450
+ dedupe: z.enum(['recipe', 'off']).optional()
451
+ }).optional();
452
+ const inlineRecipes = z.strictObject({
453
+ output: recipeObject,
454
+ inputs: z.array(recipeObject).min(1),
455
+ options
456
+ });
457
+ const namedRecipes = z.strictObject({
458
+ recipe: z.strictObject({
459
+ name: z.string().min(1),
460
+ version: z.string().min(1)
461
+ }),
462
+ options
463
+ });
464
+ /** A `/crawl` body: the recipes themselves, or the name and version of a stored set. */
465
+ const crawlRequestSchema = z.union([inlineRecipes, namedRecipes]);
466
+
467
+ /**
468
+ * `POST /crawl`: checks the caller and the recipes, then starts the
469
+ * orchestration. Recipes that do not load are a 400 before anything is
470
+ * queued.
471
+ *
472
+ * @param request - The request.
473
+ * @param client - The Durable client.
474
+ * @param settings - The host settings.
475
+ * @param orchestration - The `/crawl` orchestration.
476
+ * @returns 202 with the Durable status URLs, or the problem.
477
+ */
478
+ async function startCrawl(request, client, settings, orchestration) {
479
+ const access = callerAccess(request, settings);
480
+ if (access === undefined) return problem(403, 'this caller may not run recipes here');
481
+ const parsed = crawlRequestSchema.safeParse(await readJson(request));
482
+ if (!parsed.success) return problem(400, `the body must be { output, inputs, options? } or { recipe: { name, version }, options? }: ${parsed.error.message}`);
483
+ let recipes;
484
+ try {
485
+ if ('recipe' in parsed.data) {
486
+ const stored = await findRecipes(settings.recipes, parsed.data.recipe.name, parsed.data.recipe.version);
487
+ recipes = stored.recipes;
488
+ } else {
489
+ recipes = [parsed.data.output, ...parsed.data.inputs];
490
+ }
491
+ await loadRecipes(recipes);
492
+ } catch (error) {
493
+ return problem(error instanceof RecipeStoreError ? error.status : 400, error);
494
+ }
495
+ const output = recipes.find(recipe => kindOf$1(recipe) === 'output');
496
+ const job = {
497
+ ...(access.caller !== undefined && {
498
+ caller: access.caller
499
+ }),
500
+ allowedHosts: [...access.allowedHosts],
501
+ output,
502
+ inputs: recipes.filter(recipe => recipe !== output),
503
+ ...(parsed.data.options?.dedupe !== undefined && {
504
+ dedupe: parsed.data.options.dedupe
505
+ })
506
+ };
507
+ const instanceId = await startOrchestration(client, orchestration, job);
508
+ return client.createCheckStatusResponse(request, instanceId);
509
+ }
510
+ function kindOf$1(recipe) {
511
+ return typeof recipe === 'object' && recipe !== null && 'kind' in recipe ? recipe.kind : undefined;
512
+ }
513
+
514
+ const crawlJobSchema = z.object({
515
+ caller: z.string().optional(),
516
+ allowedHosts: z.array(z.string()).min(1),
517
+ output: z.unknown(),
518
+ inputs: z.array(z.unknown()).min(1),
519
+ dedupe: z.enum(['recipe', 'off']).optional()
520
+ });
521
+ /**
522
+ * Checks a `/crawl` orchestration's input as it comes back out of the task
523
+ * hub: an instance started before a deploy resumes against the new code.
524
+ *
525
+ * @param raw - The stored input.
526
+ * @returns The job.
527
+ * @throws ZodError when it is not one.
528
+ */
529
+ function parseCrawlJob(raw) {
530
+ return crawlJobSchema.parse(raw);
531
+ }
532
+
533
+ /** Names and versions end up in blob names: letters, digits, `_`, `.`, `-`. */
534
+ const NAME = /^[\w.-]{1,64}$/;
535
+ const text = value => ({
536
+ content: [{
537
+ type: 'text',
538
+ text: JSON.stringify(value)
539
+ }],
540
+ structuredContent: value
541
+ });
542
+ const failed = error => ({
543
+ content: [{
544
+ type: 'text',
545
+ text: error instanceof Error ? error.message : String(error)
546
+ }],
547
+ isError: true
548
+ });
549
+ /**
550
+ * `probe`: where a page's data lives, fetched from the host itself (its IP,
551
+ * its proxies) and only from the caller's hosts.
552
+ *
553
+ * @param context - The call's context.
554
+ * @param args - The page, whether to render it, an access profile.
555
+ * @returns The findings.
556
+ */
557
+ async function probe(context, args) {
558
+ const {
559
+ settings
560
+ } = context;
561
+ try {
562
+ return text(await probeUrl(args.url, {
563
+ browser: args.browser ?? false,
564
+ insecureTls: settings.browser?.ignoreHTTPSErrors === true,
565
+ browserPath: settings.browser?.executablePath,
566
+ allowedHosts: [...context.access.allowedHosts],
567
+ accessConfig: settings.access ?? {
568
+ profiles: {}
569
+ },
570
+ accessPlugins: settings.accessPlugins,
571
+ ...(args.access !== undefined && {
572
+ accessProfile: args.access
573
+ })
574
+ }));
575
+ } catch (error) {
576
+ return failed(error);
577
+ }
578
+ }
579
+ /**
580
+ * `validate`: `@opencraw/mcp`'s own, on inline recipes only (never paths on the host).
581
+ *
582
+ * @param args - The recipes.
583
+ * @returns Every issue with its JSON path.
584
+ */
585
+ async function validate(args) {
586
+ return await validateTool({
587
+ recipes: args.recipes
588
+ });
589
+ }
590
+ /**
591
+ * `run`: a sample by default (a few records per input recipe, within the
592
+ * time a tool call may take), or with `full` a Durable crawl whose instance
593
+ * id `status` reads. Drafts run here; production routes refuse them.
594
+ *
595
+ * @param context - The call's context.
596
+ * @param args - Inline recipes or a stored set, and whether to run it all.
597
+ * @returns The sample's reports and records, or the instance id.
598
+ */
599
+ async function run(context, args) {
600
+ const {
601
+ settings,
602
+ access
603
+ } = context;
604
+ let recipes;
605
+ try {
606
+ recipes = await recipesOf(context, args);
607
+ await loadRecipes(recipes);
608
+ } catch (error) {
609
+ return failed(error);
610
+ }
611
+ const output = recipes.find(recipe => kindOf(recipe) === 'output');
612
+ const inputs = recipes.filter(recipe => recipe !== output);
613
+ if (args.full === true) {
614
+ const job = {
615
+ ...(access.caller !== undefined && {
616
+ caller: access.caller
617
+ }),
618
+ allowedHosts: [...access.allowedHosts],
619
+ output,
620
+ inputs
621
+ };
622
+ const instanceId = await startOrchestration(context.client, context.crawl, job);
623
+ return text({
624
+ instanceId,
625
+ next: 'call status with this instance id until runtimeStatus is Completed'
626
+ });
627
+ }
628
+ const sample = inputs.map(input => withMaxRecords(input, settings.mcp.sampleRecords));
629
+ const sink = memorySink();
630
+ const crawler = createCrawler({
631
+ ...crawlOptionsFor(settings, access.allowedHosts),
632
+ sink
633
+ });
634
+ const stop = new AbortController();
635
+ try {
636
+ const set = await loadRecipes([output, ...sample]);
637
+ const finished = await Promise.race([crawler.run(set), setTimeout(settings.mcp.sampleMs, undefined, {
638
+ signal: stop.signal
639
+ })]);
640
+ const timedOut = finished === undefined;
641
+ return text({
642
+ recipes: finished?.recipes ?? [],
643
+ records: sink.records,
644
+ ...(timedOut && {
645
+ stopped: `after ${settings.mcp.sampleMs} ms; these are the records found by then`
646
+ })
647
+ });
648
+ } catch (error) {
649
+ return failed(error);
650
+ } finally {
651
+ stop.abort();
652
+ await crawler.close();
653
+ }
654
+ }
655
+ /**
656
+ * `status`: a full run's state and result, for the caller who started it.
657
+ *
658
+ * @param context - The call's context.
659
+ * @param args - The instance id `run` returned.
660
+ * @returns Its runtime status, progress and output.
661
+ */
662
+ async function status(context, args) {
663
+ try {
664
+ const found = await context.client.getStatus(args.instanceId, {
665
+ showInput: true
666
+ });
667
+ const job = found.input;
668
+ if (job?.caller !== context.access.caller) return failed(`no crawl "${args.instanceId}" of yours`);
669
+ return text({
670
+ runtimeStatus: found.runtimeStatus,
671
+ progress: found.customStatus,
672
+ output: found.output
673
+ });
674
+ } catch {
675
+ return failed(`no crawl "${args.instanceId}" of yours`);
676
+ }
677
+ }
678
+ /**
679
+ * `list_recipes`: the stored recipe sets, with their versions and states.
680
+ *
681
+ * @param context - The call's context.
682
+ * @returns The list.
683
+ */
684
+ async function listRecipes(context) {
685
+ return text({
686
+ recipes: (await context.settings.recipes?.list()) ?? []
687
+ });
688
+ }
689
+ /**
690
+ * `get_recipes`: a stored set's recipes, drafts included: the start of a new version.
691
+ *
692
+ * @param context - The call's context.
693
+ * @param args - The name and version.
694
+ * @returns The set.
695
+ */
696
+ async function getRecipes(context, args) {
697
+ try {
698
+ return text(await findRecipes(context.settings.recipes, args.name, args.version, true));
699
+ } catch (error) {
700
+ return failed(error);
701
+ }
702
+ }
703
+ /**
704
+ * `publish`: saves recipes that load as a new version, as a draft. A person
705
+ * (or a caller allowed to) promotes it before production runs it.
706
+ *
707
+ * @param context - The call's context.
708
+ * @param args - The name, a new version, the recipes.
709
+ * @returns The stored version.
710
+ */
711
+ async function publish(context, args) {
712
+ const store = context.settings.recipes;
713
+ if (store === undefined) return failed('this host has no recipe store to publish to');
714
+ if (!NAME.test(args.name) || !NAME.test(args.version)) return failed('name and version: 1 to 64 letters, digits, "_", "." or "-"');
715
+ try {
716
+ await loadRecipes(args.recipes);
717
+ await store.put({
718
+ name: args.name,
719
+ version: args.version,
720
+ recipes: args.recipes
721
+ });
722
+ } catch (error) {
723
+ return failed(error);
724
+ }
725
+ return text({
726
+ name: args.name,
727
+ version: args.version,
728
+ state: 'draft',
729
+ next: 'a person promotes it: POST /recipes/{name}/{version}/promote'
730
+ });
731
+ }
732
+ async function recipesOf(context, args) {
733
+ if (args.recipes === undefined === (args.recipe === undefined)) throw new RecipeStoreError('give exactly one of "recipes" or "recipe"', 404);
734
+ if (args.recipes !== undefined) return args.recipes;
735
+ const stored = await findRecipes(context.settings.recipes, args.recipe?.name ?? '', args.recipe?.version ?? '', true);
736
+ return stored.recipes;
737
+ }
738
+ /** An input recipe that stops after `limit` records, or sooner if it already did. */
739
+ function withMaxRecords(input, limit) {
740
+ if (typeof input !== 'object' || input === null) return input;
741
+ const limits = input.limits;
742
+ return {
743
+ ...input,
744
+ limits: {
745
+ ...limits,
746
+ maxRecords: Math.min(limits?.maxRecords ?? limit, limit)
747
+ }
748
+ };
749
+ }
750
+ function kindOf(recipe) {
751
+ return typeof recipe === 'object' && recipe !== null && 'kind' in recipe ? recipe.kind : undefined;
752
+ }
753
+
754
+ const SERVER_INFO = {
755
+ name: 'opencraw-host',
756
+ version: '0.0.1'
757
+ };
758
+ const recipeObjects = z.array(z.record(z.string(), z.unknown())).min(1);
759
+ const stored = z.object({
760
+ name: z.string(),
761
+ version: z.string()
762
+ });
763
+ /**
764
+ * The authoring MCP server for one call: the tools an agent writes recipes
765
+ * with, run where the recipes will run, for this caller only. Unlike the
766
+ * local `@opencraw/mcp`, nothing takes a path: recipes travel inline, and a
767
+ * finished set is published to the host's store.
768
+ *
769
+ * @param context - The host, the caller, Durable.
770
+ * @returns The server, ready to `connect`.
771
+ */
772
+ function createAuthoringServer(context) {
773
+ const server = new McpServer(SERVER_INFO);
774
+ server.registerTool('probe', {
775
+ description: 'Fetch a page from the crawl host (its IP, its proxies) and report where its data lives: JSON-LD, inline JSON, .json URLs, API-looking links, tables, and for files their rows and headers. Only the hosts this caller may reach.',
776
+ inputSchema: {
777
+ url: z.string().describe('The page to fetch and inspect.'),
778
+ browser: z.boolean().optional().describe('Also render the page and list the JSON responses it fetches while settling. Slower.'),
779
+ access: z.string().optional().describe('An access profile (a proxy) the host has.')
780
+ }
781
+ }, async args => await probe(context, args));
782
+ server.registerTool('validate', {
783
+ description: 'Load and bind recipes passed inline (an output recipe plus its input recipes): every problem with its JSON path.',
784
+ inputSchema: {
785
+ recipes: z.union([z.string(), recipeObjects]).describe('The recipes: an array of recipe objects, or JSON / JSON Lines text.')
786
+ }
787
+ }, async args => await validate(args));
788
+ server.registerTool('run', {
789
+ description: 'Run recipes on the crawl host. By default a sample: a few records per input recipe, returned inline, within a tool call\'s time. With full: true, a complete crawl in the background; poll status with the instance id it returns.',
790
+ inputSchema: {
791
+ recipes: recipeObjects.optional().describe('The recipes inline. Give this or "recipe".'),
792
+ recipe: stored.optional().describe('A stored set (drafts included). Give this or "recipes".'),
793
+ full: z.boolean().optional().describe('Run everything, in the background.')
794
+ }
795
+ }, async args => await run(context, args));
796
+ server.registerTool('status', {
797
+ description: 'The state of a full run: runtimeStatus, progress, and when completed the reports, records and result links.',
798
+ inputSchema: {
799
+ instanceId: z.string()
800
+ }
801
+ }, async args => await status(context, args));
802
+ server.registerTool('list_recipes', {
803
+ description: 'The recipe sets the host stores: names, versions, draft or promoted.'
804
+ }, async () => await listRecipes(context));
805
+ server.registerTool('get_recipes', {
806
+ description: 'A stored set\'s recipes (drafts included): where to start a new version from.',
807
+ inputSchema: {
808
+ name: z.string(),
809
+ version: z.string()
810
+ }
811
+ }, async args => await getRecipes(context, args));
812
+ server.registerTool('publish', {
813
+ description: 'Save recipes that load as a new version of a named set, as a draft. Production refuses drafts until a person promotes them. A version, once published, never changes: publish the next one instead.',
814
+ inputSchema: {
815
+ name: z.string(),
816
+ version: z.string(),
817
+ recipes: recipeObjects
818
+ }
819
+ }, async args => await publish(context, args));
820
+ return server;
821
+ }
822
+
823
+ /**
824
+ * `/mcp`: the authoring MCP server over Streamable HTTP, stateless: every
825
+ * request gets its own server and transport, and a JSON answer, so any
826
+ * instance can take any call.
827
+ *
828
+ * @param request - The request.
829
+ * @param client - The Durable client, for full runs.
830
+ * @param settings - The host settings.
831
+ * @param crawl - The `/crawl` orchestration.
832
+ * @returns The MCP answer, 403 for a caller with no hosts, 404 when the endpoint is off.
833
+ */
834
+ async function mcpEndpoint(request, client, settings, crawl) {
835
+ const {
836
+ mcp
837
+ } = settings;
838
+ if (mcp === undefined) return problem(404, 'the MCP endpoint is off on this host');
839
+ const access = callerAccess(request, settings);
840
+ if (access === undefined) return problem(403, 'this caller may not run recipes here');
841
+ const server = createAuthoringServer({
842
+ settings: {
843
+ ...settings,
844
+ mcp
845
+ },
846
+ access,
847
+ client,
848
+ crawl
849
+ });
850
+ const transport = new WebStandardStreamableHTTPServerTransport({
851
+ sessionIdGenerator: undefined,
852
+ enableJsonResponse: true
853
+ });
854
+ try {
855
+ await server.connect(transport);
856
+ const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text();
857
+ const response = await transport.handleRequest(new Request(request.url, {
858
+ method: request.method,
859
+ headers: request.headers,
860
+ body
861
+ }));
862
+ return {
863
+ status: response.status,
864
+ headers: Object.fromEntries(response.headers),
865
+ body: await response.text()
866
+ };
867
+ } finally {
868
+ await server.close();
869
+ }
870
+ }
871
+
872
+ /**
873
+ * `POST /recipes/{name}/{version}/promote`: a draft becomes runnable in
874
+ * production. Only for callers `canPromote` allows: an agent publishes, a
875
+ * person promotes.
876
+ *
877
+ * @param request - The request.
878
+ * @param settings - The host settings.
879
+ * @returns The promoted version, 403, or the store's 404.
880
+ */
881
+ async function promoteRecipes(request, settings) {
882
+ const access = callerAccess(request, settings);
883
+ if (access === undefined || !settings.canPromote(access.caller)) return problem(403, 'this caller may not promote recipes');
884
+ const {
885
+ name = '',
886
+ version = ''
887
+ } = request.params;
888
+ if (settings.recipes === undefined) return problem(404, 'this host has no recipe store');
889
+ try {
890
+ await settings.recipes.promote(name, version);
891
+ } catch (error) {
892
+ return problem(error instanceof RecipeStoreError ? error.status : 500, error);
893
+ }
894
+ return reply(200, {
895
+ name,
896
+ version,
897
+ state: 'promoted'
898
+ });
899
+ }
900
+
901
+ /**
902
+ * A job's windows, within the host's limit and never more than the job has
903
+ * items: twelve windows for three items would only sit idle.
904
+ *
905
+ * @param policy - What the job asked for.
906
+ * @param maxWindows - The host's limit.
907
+ * @param jobSize - About how many items the job has.
908
+ * @returns The policy, capped.
909
+ */
910
+ function capWindows(policy, maxWindows, jobSize) {
911
+ const asked = typeof policy === 'number' ? {
912
+ min: policy
913
+ } : policy;
914
+ const cap = Math.max(1, Math.min(maxWindows, jobSize ?? maxWindows));
915
+ const max = Math.min(asked.max ?? asked.min ?? 1, cap);
916
+ const min = Math.min(asked.min ?? 1, max);
917
+ const start = Math.min(Math.max(asked.start ?? min, min), max);
918
+ return {
919
+ ...asked,
920
+ min,
921
+ max,
922
+ start
923
+ };
924
+ }
925
+
926
+ /** Records go back to each item's caller, so the pool keeps none of its own. */
927
+ const discardSink = () => ({
928
+ open: async () => {},
929
+ write: async () => {},
930
+ close: async () => ({
931
+ written: 0
932
+ })
933
+ });
934
+ /** How often idle pools are looked for, at most. */
935
+ const SWEEP_MS = 30_000;
936
+ /**
937
+ * The warm worker pools of this process, one per caller and crawl id. A pool
938
+ * is created by its first item, runs `crawler.work` on an inbox, and closes
939
+ * when its job size is reached, when it has had no item for `idleTtlMs`, or
940
+ * when asked. Pools live in this process's memory: a Function App scaled to
941
+ * several instances has one pool per instance for the same crawl id.
942
+ */
943
+ class WorkerPools {
944
+ settings;
945
+ now;
946
+ pools = new Map();
947
+ /** Each pool's recipe version, known from the moment it starts opening. */
948
+ versions = new Map();
949
+ live = new Map();
950
+ sweeper;
951
+ constructor(settings, now = Date.now) {
952
+ this.settings = settings;
953
+ this.now = now;
954
+ }
955
+ open(job) {
956
+ const current = this.pools.get(job.key);
957
+ if (current !== undefined) return current;
958
+ this.versions.set(job.key, job.recipe);
959
+ const opening = this.create(job);
960
+ this.pools.set(job.key, opening);
961
+ void (async () => {
962
+ try {
963
+ await opening;
964
+ } catch {
965
+ this.forget(job.key, opening);
966
+ }
967
+ })();
968
+ return opening;
969
+ }
970
+ async create(job) {
971
+ const stored = await findRecipes(this.settings.recipes, job.recipe.name, job.recipe.version);
972
+ const set = await loadRecipes(stored.recipes);
973
+ const windows = capWindows(job.windows ?? this.settings.pools.windows, this.settings.pools.maxWindows, job.jobSize);
974
+ const pool = {
975
+ key: job.key,
976
+ crawlId: job.crawlId,
977
+ recipe: job.recipe,
978
+ inbox: createWorkInbox(),
979
+ windows: windows.start ?? windows.min ?? 1,
980
+ ended: {
981
+ success: 0,
982
+ failure: 0,
983
+ neutral: 0
984
+ },
985
+ lastActive: this.now(),
986
+ working: Promise.resolve(undefined),
987
+ crawler: createCrawler({
988
+ ...crawlOptionsFor(this.settings, job.allowedHosts),
989
+ sink: discardSink(),
990
+ onEvent: event => {
991
+ if (event.type === 'windows:change') pool.windows = event.to;
992
+ }
993
+ }),
994
+ ...(job.jobSize !== undefined && {
995
+ jobSize: job.jobSize
996
+ })
997
+ };
998
+ pool.working = this.work(pool, pool.crawler.work(set, pool.inbox.source, {
999
+ windows
1000
+ }));
1001
+ this.live.set(job.key, pool);
1002
+ this.sweep();
1003
+ return pool;
1004
+ }
1005
+ /** Waits for a pool to run dry, then closes its browser and forgets it; a pool that failed rejects what it still held. */
1006
+ async work(pool, working) {
1007
+ try {
1008
+ return await working;
1009
+ } catch (error) {
1010
+ pool.inbox.abort(error);
1011
+ return undefined;
1012
+ } finally {
1013
+ pool.inbox.close();
1014
+ await pool.crawler.close();
1015
+ const opening = this.pools.get(pool.key);
1016
+ if (opening !== undefined && this.live.get(pool.key) === pool) this.forget(pool.key, opening);
1017
+ }
1018
+ }
1019
+ forget(key, opening) {
1020
+ if (this.pools.get(key) !== opening) return;
1021
+ this.pools.delete(key);
1022
+ this.versions.delete(key);
1023
+ this.live.delete(key);
1024
+ }
1025
+ /** Starts looking for pools that have had no item for `idleTtlMs`, while any is open. */
1026
+ sweep() {
1027
+ if (this.sweeper !== undefined) return;
1028
+ const ttl = this.settings.pools.idleTtlMs;
1029
+ const every = Math.max(1, Math.min(SWEEP_MS, Math.floor(ttl / 2)));
1030
+ this.sweeper = setInterval(() => {
1031
+ const now = this.now();
1032
+ for (const pool of this.live.values()) {
1033
+ if (pool.inbox.running === 0 && pool.inbox.queued === 0 && now - pool.lastActive >= ttl) pool.inbox.close();
1034
+ }
1035
+ if (this.live.size === 0) {
1036
+ clearInterval(this.sweeper);
1037
+ this.sweeper = undefined;
1038
+ }
1039
+ }, every);
1040
+ this.sweeper.unref();
1041
+ }
1042
+ /**
1043
+ * The recipe version a pool runs, if one is open under this key.
1044
+ *
1045
+ * @param key - The caller and crawl id.
1046
+ * @returns The version.
1047
+ */
1048
+ versionOf(key) {
1049
+ return this.versions.get(key);
1050
+ }
1051
+ /**
1052
+ * @param key - The caller and crawl id.
1053
+ * @returns The pool as a caller sees it, if one is open.
1054
+ */
1055
+ state(key) {
1056
+ const pool = this.live.get(key);
1057
+ return pool === undefined ? undefined : stateOf(pool);
1058
+ }
1059
+ /**
1060
+ * Runs an item on its job's pool, opening the pool when it is the first.
1061
+ *
1062
+ * @param job - The item and its job.
1063
+ * @returns How it ended, with the pool's state.
1064
+ */
1065
+ async run(job) {
1066
+ const running = this.versions.get(job.key);
1067
+ if (running !== undefined && (running.name !== job.recipe.name || running.version !== job.recipe.version)) {
1068
+ return {
1069
+ outcome: 'refused',
1070
+ error: `crawl "${job.crawlId}" runs recipes "${running.name}" version "${running.version}"; close it or use another crawl id to run version "${job.recipe.version}"`
1071
+ };
1072
+ }
1073
+ let pool;
1074
+ try {
1075
+ pool = await this.open(job);
1076
+ // A pool that is closing takes no more items: wait for it to go, and open the job's next one.
1077
+ if (pool.inbox.closed) {
1078
+ await pool.working;
1079
+ pool = await this.open(job);
1080
+ }
1081
+ } catch (error) {
1082
+ return {
1083
+ outcome: 'refused',
1084
+ error: error instanceof Error ? error.message : String(error)
1085
+ };
1086
+ }
1087
+ pool.lastActive = this.now();
1088
+ let result;
1089
+ try {
1090
+ result = await pool.inbox.submit(job.item);
1091
+ } catch (error) {
1092
+ return {
1093
+ outcome: 'failure',
1094
+ error: error instanceof Error ? error.message : String(error),
1095
+ pool: stateOf(pool)
1096
+ };
1097
+ } finally {
1098
+ pool.lastActive = this.now();
1099
+ }
1100
+ pool.ended[result.outcome] += 1;
1101
+ const ended = pool.ended.success + pool.ended.failure + pool.ended.neutral;
1102
+ if (pool.jobSize !== undefined && ended >= pool.jobSize) pool.inbox.close();
1103
+ if (result.outcome !== 'success') return {
1104
+ outcome: result.outcome,
1105
+ report: result.report,
1106
+ ...(result.report.error !== undefined && {
1107
+ error: result.report.error
1108
+ }),
1109
+ pool: stateOf(pool)
1110
+ };
1111
+ const packed = await packRecords(result.records, {
1112
+ store: this.settings.results,
1113
+ inlineLimitBytes: this.settings.inlineLimitBytes,
1114
+ name: job.resultName
1115
+ });
1116
+ return {
1117
+ outcome: 'success',
1118
+ report: result.report,
1119
+ ...packed,
1120
+ pool: stateOf(pool)
1121
+ };
1122
+ }
1123
+ /**
1124
+ * Closes a pool: queued and running items finish, then its browser closes.
1125
+ *
1126
+ * @param key - The caller and crawl id.
1127
+ * @returns Whether there was a pool to close.
1128
+ */
1129
+ async close(key) {
1130
+ const opening = this.pools.get(key);
1131
+ if (opening === undefined) return false;
1132
+ let pool;
1133
+ try {
1134
+ pool = await opening;
1135
+ } catch {
1136
+ return false;
1137
+ }
1138
+ pool.inbox.close();
1139
+ await pool.working;
1140
+ return true;
1141
+ }
1142
+ /** Closes every pool, as the host shuts down. */
1143
+ async closeAll() {
1144
+ if (this.sweeper !== undefined) clearInterval(this.sweeper);
1145
+ this.sweeper = undefined;
1146
+ await Promise.all(Array.from(this.pools.keys(), key => this.close(key)));
1147
+ }
1148
+ }
1149
+ function stateOf(pool) {
1150
+ return {
1151
+ crawlId: pool.crawlId,
1152
+ recipe: pool.recipe,
1153
+ windows: pool.windows,
1154
+ idle: pool.inbox.idle,
1155
+ queued: pool.inbox.queued,
1156
+ running: pool.inbox.running,
1157
+ ended: {
1158
+ ...pool.ended
1159
+ },
1160
+ closing: pool.inbox.closed
1161
+ };
1162
+ }
1163
+
1164
+ /**
1165
+ * A pooled item's orchestration: one activity, which hands the item to the
1166
+ * warm pool and waits for its result. Durable adds what a plain HTTP call
1167
+ * lacks: a status URL to poll past the HTTP time limit, and an instance id
1168
+ * that refuses a duplicate while the item runs.
1169
+ *
1170
+ * @param runItem - The activity.
1171
+ * @returns The orchestrator body.
1172
+ */
1173
+ function itemOrchestrator(runItem) {
1174
+ return function* item(context, job) {
1175
+ return yield* callActivity(context, runItem, job);
1176
+ };
1177
+ }
1178
+
1179
+ /** Ids end up in Durable instance ids and blob names: letters, digits, `_`, `.`, `-`. */
1180
+ const id = z.string().regex(/^[\w.-]{1,64}$/, 'use 1 to 64 letters, digits, "_", "." or "-"');
1181
+ const count = z.number().int().positive();
1182
+ const after = z.strictObject({
1183
+ after: count.optional()
1184
+ });
1185
+ const idle = z.strictObject({
1186
+ afterMs: count
1187
+ });
1188
+ const windowsPolicy = z.strictObject({
1189
+ min: count.optional(),
1190
+ max: count.optional(),
1191
+ start: count.optional(),
1192
+ grow: after.optional(),
1193
+ shrink: z.enum(['one', 'half']).optional(),
1194
+ restart: after.optional(),
1195
+ idle: idle.optional()
1196
+ });
1197
+ const windows = z.union([count, windowsPolicy]);
1198
+ const varValue$1 = z.union([z.string(), z.number(), z.boolean()]);
1199
+ const recipeVersion = z.strictObject({
1200
+ name: z.string().min(1),
1201
+ version: z.string().min(1)
1202
+ });
1203
+ const workItem$1 = z.strictObject({
1204
+ id,
1205
+ vars: z.record(z.string(), varValue$1),
1206
+ recipe: z.string().optional()
1207
+ });
1208
+ const crawlIdSchema = id;
1209
+ /** A `/jobs/{crawlId}/items` body. */
1210
+ const itemRequestSchema = z.strictObject({
1211
+ recipe: recipeVersion,
1212
+ item: workItem$1,
1213
+ /** How many items the job has, about: caps the windows and closes the pool once that many ended. */
1214
+ jobSize: count.optional(),
1215
+ /** The pool's windows, when this item opens it; capped by the host's `pools.maxWindows`. */
1216
+ windows: windows.optional()
1217
+ });
1218
+
1219
+ /** Durable statuses of an instance still in progress: a duplicate of it gets its status, not a second run. */
1220
+ const IN_PROGRESS = new Set(['Pending', 'Running', 'ContinuedAsNew', 'Suspended']);
1221
+ /**
1222
+ * `POST /jobs/{crawlId}/items`: one item for the caller's warm pool. The
1223
+ * Durable instance id is the caller, crawl id and item id, so the same item
1224
+ * sent again while it runs returns the running instance's status.
1225
+ *
1226
+ * @param request - The request.
1227
+ * @param client - The Durable client.
1228
+ * @param settings - The host settings.
1229
+ * @param pools - This process's pools.
1230
+ * @param orchestration - The item orchestration.
1231
+ * @returns 202 with the Durable status URLs, or the problem.
1232
+ */
1233
+ async function submitItem(request, client, settings, pools, orchestration) {
1234
+ const access = callerAccess(request, settings);
1235
+ if (access === undefined) return problem(403, 'this caller may not run recipes here');
1236
+ const crawlId = crawlIdSchema.safeParse(request.params.crawlId);
1237
+ if (!crawlId.success) return problem(400, `crawl id: ${crawlId.error.issues[0]?.message ?? 'invalid'}`);
1238
+ const parsed = itemRequestSchema.safeParse(await readJson(request));
1239
+ if (!parsed.success) return problem(400, `the body must be { recipe: { name, version }, item: { id, vars }, jobSize?, windows? }: ${parsed.error.message}`);
1240
+ const {
1241
+ recipe,
1242
+ item,
1243
+ jobSize,
1244
+ windows
1245
+ } = parsed.data;
1246
+ const key = poolKey(access, crawlId.data);
1247
+ const running = pools.versionOf(key);
1248
+ if (running !== undefined && (running.name !== recipe.name || running.version !== recipe.version)) return problem(409, `crawl "${crawlId.data}" runs recipes "${running.name}" version "${running.version}"`);
1249
+ try {
1250
+ await findRecipes(settings.recipes, recipe.name, recipe.version);
1251
+ } catch (error) {
1252
+ return problem(error instanceof RecipeStoreError ? error.status : 400, error);
1253
+ }
1254
+ const instanceId = `${callerTag(access.caller)}${crawlId.data}:${item.id}`;
1255
+ if (IN_PROGRESS.has((await statusOf(client, instanceId)) ?? '')) return client.createCheckStatusResponse(request, instanceId);
1256
+ const job = {
1257
+ key,
1258
+ crawlId: crawlId.data,
1259
+ allowedHosts: [...access.allowedHosts],
1260
+ recipe,
1261
+ item,
1262
+ resultName: `${instanceId.replaceAll(':', '/')}.jsonl`,
1263
+ ...(jobSize !== undefined && {
1264
+ jobSize
1265
+ }),
1266
+ ...(windows !== undefined && {
1267
+ windows
1268
+ })
1269
+ };
1270
+ await startOrchestration(client, orchestration, job, {
1271
+ instanceId
1272
+ });
1273
+ return client.createCheckStatusResponse(request, instanceId);
1274
+ }
1275
+ /** Two callers may use the same crawl and item ids: a short hash of the caller keeps their instances apart. */
1276
+ function callerTag(caller) {
1277
+ return caller === undefined ? '' : `${createHash('sha256').update(caller).digest('hex').slice(0, 12)}:`;
1278
+ }
1279
+ /** The instance's runtime status; `undefined` when there is none (the client throws for an unknown instance). */
1280
+ async function statusOf(client, instanceId) {
1281
+ try {
1282
+ const status = await client.getStatus(instanceId);
1283
+ return status.runtimeStatus;
1284
+ } catch {
1285
+ return undefined;
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * `GET /jobs/{crawlId}`: the pool's state. `DELETE /jobs/{crawlId}`: closes
1291
+ * it; queued and running items finish first.
1292
+ *
1293
+ * @param request - The request.
1294
+ * @param settings - The host settings.
1295
+ * @param pools - This process's pools.
1296
+ * @returns The state, 202 when closing, 404 when there is no pool.
1297
+ */
1298
+ async function jobRequest(request, settings, pools) {
1299
+ const access = callerAccess(request, settings);
1300
+ if (access === undefined) return problem(403, 'this caller may not run recipes here');
1301
+ const crawlId = crawlIdSchema.safeParse(request.params.crawlId);
1302
+ if (!crawlId.success) return problem(400, `crawl id: ${crawlId.error.issues[0]?.message ?? 'invalid'}`);
1303
+ const key = poolKey(access, crawlId.data);
1304
+ const state = pools.state(key);
1305
+ if (state === undefined) return problem(404, `no pool for crawl "${crawlId.data}" in this instance`);
1306
+ if (request.method === 'DELETE') {
1307
+ void pools.close(key);
1308
+ return reply(202, {
1309
+ ...state,
1310
+ closing: true
1311
+ });
1312
+ }
1313
+ return reply(200, state);
1314
+ }
1315
+
1316
+ const varValue = z.union([z.string(), z.number(), z.boolean()]);
1317
+ const workItem = z.object({
1318
+ id: z.string(),
1319
+ vars: z.record(z.string(), varValue),
1320
+ recipe: z.string().optional()
1321
+ });
1322
+ const itemJobSchema = z.object({
1323
+ key: z.string(),
1324
+ crawlId: z.string(),
1325
+ allowedHosts: z.array(z.string()).min(1),
1326
+ recipe: z.object({
1327
+ name: z.string(),
1328
+ version: z.string()
1329
+ }),
1330
+ item: workItem,
1331
+ jobSize: z.number().optional(),
1332
+ windows: z.unknown().optional(),
1333
+ resultName: z.string()
1334
+ });
1335
+ /**
1336
+ * Checks a pooled item's orchestration input as it comes back out of the task hub.
1337
+ *
1338
+ * @param raw - The stored input.
1339
+ * @returns The job.
1340
+ * @throws ZodError when it is not one.
1341
+ */
1342
+ function parseItemJob(raw) {
1343
+ return itemJobSchema.parse(raw);
1344
+ }
1345
+
1346
+ /**
1347
+ * What in the Function App's `host.json` would make the host misbehave. An
1348
+ * item waiting in a pool's inbox holds an activity slot, so a pool can only
1349
+ * fill its windows if Durable runs that many activities at once.
1350
+ *
1351
+ * @param maxWindows - The most windows one pool may have.
1352
+ * @param directory - Where `host.json` is; the working directory by default.
1353
+ * @returns Warnings, none when `host.json` is missing or fine.
1354
+ */
1355
+ function hostJsonWarnings(maxWindows, directory = process.cwd()) {
1356
+ let host;
1357
+ try {
1358
+ host = JSON.parse(readFileSync(join(directory, 'host.json'), 'utf8'));
1359
+ } catch {
1360
+ return [];
1361
+ }
1362
+ const slots = host.extensions?.durableTask?.maxConcurrentActivityFunctions;
1363
+ if (typeof slots !== 'number' || slots >= maxWindows + 2) return [];
1364
+ return [`host.json: extensions.durableTask.maxConcurrentActivityFunctions is ${slots}, but one pool may run ${maxWindows} windows; items waiting in a pool hold activity slots, so set it to at least ${maxWindows + 2}`];
1365
+ }
1366
+
1367
+ /**
1368
+ * Registers OpenCraw's HTTP API in this Function App (the v4 programming
1369
+ * model): `POST /crawl` runs recipes once, `POST /jobs/{crawlId}/items`
1370
+ * sends one item to the caller's warm pool, `GET` and `DELETE
1371
+ * /jobs/{crawlId}` read and close it, `POST /recipes/{name}/{version}/promote`
1372
+ * makes a draft runnable, and with `mcp` on, `/mcp` serves the authoring
1373
+ * tools. Call it once, from the app's entry module; nothing is registered by
1374
+ * importing the package.
1375
+ *
1376
+ * @param options - Allowed hosts, callers, recipes, result storage, plugins, pools.
1377
+ * @returns The registered pieces.
1378
+ * @throws Error when the options cannot work, or when it is called twice (function names are global).
1379
+ */
1380
+ function registerOpenCraw(options) {
1381
+ const settings = resolveHostOptions(options);
1382
+ const pools = new WorkerPools(settings);
1383
+ for (const warning of hostJsonWarnings(settings.pools.maxWindows)) console.warn(`[opencraw] ${warning}`);
1384
+ const runRecipe = async task => await runRecipeTask(settings, task);
1385
+ const runItem = async job => await pools.run(job);
1386
+ const crawl = defineOrchestration('OpenCrawCrawl', crawlOrchestrator(defineActivity('OpenCrawRunRecipe', runRecipe)), {
1387
+ parse: parseCrawlJob
1388
+ });
1389
+ const item = defineOrchestration('OpenCrawItem', itemOrchestrator(defineActivity('OpenCrawRunItem', runItem)), {
1390
+ parse: parseItemJob
1391
+ });
1392
+ const http = {
1393
+ startCrawl: async (request, client) => await startCrawl(request, client, settings, crawl),
1394
+ submitItem: async (request, client) => await submitItem(request, client, settings, pools, item),
1395
+ job: async request => await jobRequest(request, settings, pools),
1396
+ mcp: async (request, client) => await mcpEndpoint(request, client, settings, crawl),
1397
+ promote: async request => await promoteRecipes(request, settings)
1398
+ };
1399
+ const route = path => settings.routePrefix === '' ? path : `${settings.routePrefix}/${path}`;
1400
+ df.app.client.http('OpenCrawStartCrawl', {
1401
+ route: route('crawl'),
1402
+ methods: ['POST'],
1403
+ authLevel: settings.authLevel,
1404
+ handler: http.startCrawl
1405
+ });
1406
+ df.app.client.http('OpenCrawSubmitItem', {
1407
+ route: route('jobs/{crawlId}/items'),
1408
+ methods: ['POST'],
1409
+ authLevel: settings.authLevel,
1410
+ handler: http.submitItem
1411
+ });
1412
+ app.http('OpenCrawJob', {
1413
+ route: route('jobs/{crawlId}'),
1414
+ methods: ['GET', 'DELETE'],
1415
+ authLevel: settings.authLevel,
1416
+ handler: http.job
1417
+ });
1418
+ app.http('OpenCrawPromote', {
1419
+ route: route('recipes/{name}/{version}/promote'),
1420
+ methods: ['POST'],
1421
+ authLevel: settings.authLevel,
1422
+ handler: http.promote
1423
+ });
1424
+ if (settings.mcp !== undefined) df.app.client.http('OpenCrawMcp', {
1425
+ route: route('mcp'),
1426
+ methods: ['GET', 'POST', 'DELETE'],
1427
+ authLevel: settings.authLevel,
1428
+ handler: http.mcp
1429
+ });
1430
+ const close = async () => {
1431
+ await pools.closeAll();
1432
+ };
1433
+ app.hook.appTerminate(close);
1434
+ return {
1435
+ settings,
1436
+ pools,
1437
+ orchestrations: {
1438
+ crawl,
1439
+ item
1440
+ },
1441
+ activities: {
1442
+ runRecipe,
1443
+ runItem
1444
+ },
1445
+ http,
1446
+ close
1447
+ };
1448
+ }
1449
+
1450
+ export { RecipeStoreError, blobRecipes, blobResults, hostJsonWarnings, memoryRecipes, registerOpenCraw };
1451
+ //# sourceMappingURL=index.esm.js.map