@opencraw/azure-durable 0.0.24 → 0.0.26

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
@@ -53,6 +53,7 @@ Everything that is code or a secret stays in the host: hooks, captcha solvers, a
53
53
  | `hooks`, `captchaSolvers`, `access`, `accessPlugins`, `browser` | As in `createCrawler`. `captchaSolvers` is a factory: each crawler gets its own set. |
54
54
  | `pools` | `windows` (the default policy), `maxWindows` (the cap per pool, default 8), `idleTtlMs` (a pool with no item this long closes, default 10 minutes). |
55
55
  | `authLevel`, `routePrefix` | Default `function` (a function key) and no prefix. |
56
+ | `callouts` | Lets a hook that answers `pending` post its result back, `POST /callouts/{token}/resolve`, instead of being asked again: `{ signingKeyEnv, publicUrl?, waitMs? }`. See [the guide](https://github.com/Open-Craw/OpenCraw/blob/main/docs/recipes/azure-durable.md#8-hooks-that-take-a-while). Off by default. |
56
57
 
57
58
  ## Call it
58
59
 
package/dist/index.esm.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { app } from '@azure/functions';
2
- import { activityTask, any, resultOf, startOrchestration, callActivity, defineOrchestration, defineActivity } from '@mnci/az-durable';
2
+ import { any, resultOf, eventTask, defineEvent, timerTask, activityTask, startOrchestration, callActivity, defineOrchestration, defineActivity } from '@mnci/az-durable';
3
3
  import * as df from 'durable-functions';
4
- import { memorySink, createCrawler, loadRecipes, createWorkInbox } from '@opencraw/core';
4
+ import { timingSafeEqual, createHmac, createHash } from 'node:crypto';
5
+ import { calloutResolutionSchema, memorySink, createCrawler, loadRecipes, withCalloutWaiter, createWorkInbox } from '@opencraw/core';
5
6
  import { BlobServiceClient, BlobSASPermissions, RestError } from '@azure/storage-blob';
6
7
  import { z } from 'zod';
7
8
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
@@ -9,46 +10,317 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
10
  import { setTimeout } from 'node:timers/promises';
10
11
  import { probeUrl } from '@opencraw/cli';
11
12
  import { validateTool } from '@opencraw/mcp';
12
- import { createHash } from 'node:crypto';
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
 
16
+ const SEPARATOR = '.';
17
+ function signature(secret, payload) {
18
+ return createHmac('sha256', secret).update(payload).digest('base64url');
19
+ }
20
+ /**
21
+ * The token a handler posts its result back with. It is the ticket and an HMAC of it, so the host holds no
22
+ * state for it: any instance of the Function App can check it.
23
+ *
24
+ * @param secret - The host's signing key.
25
+ * @param ticket - What the token lets its holder do.
26
+ * @returns A URL-safe token.
27
+ */
28
+ function signCalloutTicket(secret, ticket) {
29
+ const payload = Buffer.from(JSON.stringify(ticket), 'utf8').toString('base64url');
30
+ return `${payload}${SEPARATOR}${signature(secret, payload)}`;
31
+ }
32
+ /**
33
+ * Checks a token and reads the ticket in it.
34
+ *
35
+ * @param secret - The host's signing key.
36
+ * @param token - What was posted to.
37
+ * @param now - The current time, epoch milliseconds.
38
+ * @returns The ticket, or why the token is not good.
39
+ */
40
+ function readCalloutTicket(secret, token, now) {
41
+ const [payload, given, ...rest] = token.split(SEPARATOR);
42
+ if (payload === undefined || given === undefined || rest.length > 0) return {
43
+ ok: false,
44
+ reason: 'malformed'
45
+ };
46
+ const expected = Buffer.from(signature(secret, payload));
47
+ const actual = Buffer.from(given);
48
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return {
49
+ ok: false,
50
+ reason: 'signature'
51
+ };
52
+ let ticket;
53
+ try {
54
+ ticket = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
55
+ } catch {
56
+ return {
57
+ ok: false,
58
+ reason: 'malformed'
59
+ };
60
+ }
61
+ if (typeof ticket.instanceId !== 'string' || typeof ticket.key !== 'string' || typeof ticket.index !== 'number' || typeof ticket.expiresAt !== 'number') return {
62
+ ok: false,
63
+ reason: 'malformed'
64
+ };
65
+ if (ticket.expiresAt < now) return {
66
+ ok: false,
67
+ reason: 'expired'
68
+ };
69
+ return {
70
+ ok: true,
71
+ ticket
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The name of the external event a posted-back result is raised as. The call's idempotency key is the
77
+ * same for the same call anywhere in the job, so the recipe's index keeps two recipes that make an
78
+ * identical call apart.
79
+ *
80
+ * @param index - The input recipe of the job.
81
+ * @param key - The call's idempotency key.
82
+ * @returns The event name.
83
+ */
84
+ function calloutEventName(index, key) {
85
+ return `opencraw-callout:${String(index)}:${key}`;
86
+ }
87
+ /**
88
+ * Where a handler posts a result, given the URL the job was started at.
89
+ *
90
+ * `publicUrl` wins when the host sets it (behind a proxy or a custom domain the request does not show);
91
+ * otherwise it is the start URL without its last path segment, which is where `/crawl` and
92
+ * `/callouts/{token}/resolve` are siblings.
93
+ *
94
+ * @param startUrl - The URL of the `/crawl` request.
95
+ * @param settings - The host settings.
96
+ * @returns The base all callback URLs start with, without a trailing slash.
97
+ */
98
+ function callbackBaseOf(startUrl, settings) {
99
+ const configured = settings.callouts?.publicUrl;
100
+ if (configured !== undefined) return configured.replace(/\/+$/, '');
101
+ const url = new URL(startUrl);
102
+ return `${url.origin}${url.pathname.replace(/\/[^/]*\/?$/, '')}`;
103
+ }
104
+ /**
105
+ * @param base - What `callbackBaseOf` returned.
106
+ * @param token - The signed ticket.
107
+ * @returns The URL a handler posts its `CalloutResolution` to.
108
+ */
109
+ function callbackUrl(base, token) {
110
+ return `${base}/callouts/${token}/resolve`;
111
+ }
112
+
113
+ /**
114
+ * @param status - The HTTP status.
115
+ * @param body - A JSON body.
116
+ * @returns The response.
117
+ */
118
+ function reply(status, body) {
119
+ return {
120
+ status,
121
+ jsonBody: body
122
+ };
123
+ }
124
+ /**
125
+ * An error the caller can act on: what went wrong, and the issues a recipe
126
+ * error lists (each with its JSON path).
127
+ *
128
+ * @param status - The HTTP status.
129
+ * @param error - What went wrong.
130
+ * @returns The response.
131
+ */
132
+ function problem(status, error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ const issues = typeof error === 'object' && error !== null && 'issues' in error ? error.issues : undefined;
135
+ return reply(status, {
136
+ error: message,
137
+ ...(issues !== undefined && {
138
+ issues
139
+ })
140
+ });
141
+ }
142
+ /**
143
+ * The request body as JSON.
144
+ *
145
+ * @param request - The request.
146
+ * @returns The body, or `undefined` when it is not JSON.
147
+ */
148
+ async function readJson(request) {
149
+ try {
150
+ return await request.json();
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * `POST /callouts/{token}/resolve`: a handler that answered `pending` posts the result of the call here.
158
+ * The token in the path is the credential (it is a signed ticket for that one call), so the route does
159
+ * not ask for a function key. The result is raised to the waiting job as an external event; a second post
160
+ * for a call that was already settled finds nobody waiting and is refused.
161
+ *
162
+ * @param request - The request.
163
+ * @param client - The Durable client.
164
+ * @param settings - The host settings.
165
+ * @param now - The current time, epoch milliseconds.
166
+ * @returns 202 when the result was handed to the job, or the problem.
167
+ */
168
+ async function resolveCallout(request, client, settings, now = Date.now()) {
169
+ const callouts = settings.callouts;
170
+ if (callouts === undefined) return problem(404, 'this host does not take posted-back results');
171
+ const secret = process.env[callouts.signingKeyEnv];
172
+ if (secret === undefined || secret === '') return problem(500, 'the host has no signing key for posted-back results');
173
+ const reading = readCalloutTicket(secret, request.params.token ?? '', now);
174
+ if (!reading.ok) return problem(reading.reason === 'expired' ? 410 : 401, `the token is ${reading.reason === 'expired' ? 'expired' : 'not valid'}`);
175
+ const parsed = calloutResolutionSchema.safeParse(await readJson(request));
176
+ if (!parsed.success) return problem(400, `the body must be {"status":"ok","output":…} or {"status":"error","error":"…"}: ${parsed.error.message}`);
177
+ const {
178
+ instanceId,
179
+ index,
180
+ key
181
+ } = reading.ticket;
182
+ const status = await client.getStatus(instanceId);
183
+ if (status === undefined || status.runtimeStatus !== 'Running') return problem(410, 'the job this result is for is no longer waiting for it');
184
+ await client.raiseEvent(instanceId, calloutEventName(index, key), parsed.data);
185
+ return reply(202, {
186
+ status: 'accepted'
187
+ });
188
+ }
189
+
190
+ /** A recipe is run again at most this many times for parked calls, so a hook whose key changes every run cannot loop for ever. */
191
+ const MAX_RESUMES = 25;
192
+ const DEFAULT_WAIT_MS = 60 * 60 * 1000;
16
193
  /**
17
194
  * The `/crawl` orchestration: one activity per input recipe, all at once;
18
195
  * the custom status counts recipes and records as each one finishes.
19
196
  *
197
+ * A recipe whose hooks answered `pending` and will post their results back stops its activity with
198
+ * `parked` calls. The orchestration then waits for one external event per call (and a timer, so a
199
+ * handler that never answers fails the recipe, not the job) and runs the recipe again with the results
200
+ * so far: calls that have a result return it without being made again.
201
+ *
20
202
  * @param runRecipe - The activity that runs one recipe.
203
+ * @param options - How long to wait for posted-back results.
21
204
  * @returns The orchestrator body.
22
205
  */
23
- function crawlOrchestrator(runRecipe) {
206
+ function crawlOrchestrator(runRecipe, options = {}) {
207
+ const waitMs = options.waitMs ?? DEFAULT_WAIT_MS;
24
208
  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
- })
209
+ const slots = job.inputs.map(() => ({
210
+ resolutions: {},
211
+ waiting: new Set(),
212
+ events: [],
213
+ resumes: 0,
214
+ handlers: []
33
215
  }));
34
- const finished = new Map();
216
+ const owners = new Map();
217
+ const outcomes = new Map();
35
218
  const progress = {
36
- recipes: tasks.length,
219
+ recipes: job.inputs.length,
37
220
  recipesDone: 0,
38
221
  records: 0
39
222
  };
223
+ let running = [];
224
+ let waitingRecipes = 0;
225
+ const start = index => {
226
+ const slot = slots[index];
227
+ const task = activityTask(context, runRecipe, {
228
+ allowedHosts: job.allowedHosts,
229
+ output: job.output,
230
+ input: job.inputs[index],
231
+ resultName: `${context.df.instanceId}/${String(index)}.jsonl`,
232
+ instanceId: context.df.instanceId,
233
+ index,
234
+ ...(job.dedupe !== undefined && {
235
+ dedupe: job.dedupe
236
+ }),
237
+ ...(job.callbackBase !== undefined && {
238
+ callbackBase: job.callbackBase
239
+ }),
240
+ ...(Object.keys(slot.resolutions).length > 0 && {
241
+ resolutions: {
242
+ ...slot.resolutions
243
+ }
244
+ })
245
+ });
246
+ owners.set(task, {
247
+ kind: 'recipe',
248
+ index
249
+ });
250
+ running.push(task);
251
+ };
252
+ const finish = (index, result) => {
253
+ outcomes.set(index, result);
254
+ progress.recipesDone += 1;
255
+ progress.records += result.reports.reduce((sum, report) => sum + report.emitted, 0);
256
+ context.df.setCustomStatus(progress);
257
+ };
258
+ const stopWaiting = slot => {
259
+ if (slot.timer !== undefined && !slot.timer.isCompleted()) slot.timer.cancel();
260
+ const stopped = new Set([...slot.events, ...(slot.timer === undefined ? [] : [slot.timer])]);
261
+ running = running.filter(task => !stopped.has(task));
262
+ slot.events = [];
263
+ slot.timer = undefined;
264
+ waitingRecipes -= 1;
265
+ progress.recipesWaiting = waitingRecipes;
266
+ };
40
267
  context.df.setCustomStatus(progress);
41
- let running = tasks;
268
+ for (let index = 0; index < job.inputs.length; index += 1) start(index);
42
269
  while (running.length > 0) {
43
270
  const winner = yield* any(context, running);
44
- const result = resultOf(winner);
45
- finished.set(winner, result);
46
271
  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);
272
+ const owner = owners.get(winner);
273
+ if (owner === undefined) continue;
274
+ const slot = slots[owner.index];
275
+ if (owner.kind === 'recipe') {
276
+ const result = resultOf(winner);
277
+ const parked = result.parked ?? [];
278
+ if (parked.length === 0) {
279
+ finish(owner.index, result);
280
+ } else if (slot.resumes >= MAX_RESUMES) {
281
+ finish(owner.index, {
282
+ reports: [failedReport(job.inputs[owner.index], owner.index, `still waiting for posted-back results after ${String(MAX_RESUMES)} runs: the keys of its calls keep changing`)]
283
+ });
284
+ } else {
285
+ slot.resumes += 1;
286
+ slot.waiting = new Set(parked.map(call => call.idempotencyKey));
287
+ slot.handlers = parked.map(call => call.handler);
288
+ for (const call of parked) {
289
+ const event = eventTask(context, defineEvent(calloutEventName(owner.index, call.idempotencyKey)));
290
+ owners.set(event, {
291
+ kind: 'event',
292
+ index: owner.index,
293
+ key: call.idempotencyKey
294
+ });
295
+ slot.events.push(event);
296
+ running.push(event);
297
+ }
298
+ slot.timer = timerTask(context, waitMs);
299
+ owners.set(slot.timer, {
300
+ kind: 'timer',
301
+ index: owner.index
302
+ });
303
+ running.push(slot.timer);
304
+ waitingRecipes += 1;
305
+ progress.recipesWaiting = waitingRecipes;
306
+ context.df.setCustomStatus(progress);
307
+ }
308
+ } else if (owner.kind === 'event') {
309
+ slot.resolutions[owner.key] = resultOf(winner);
310
+ slot.waiting.delete(owner.key);
311
+ if (slot.waiting.size === 0) {
312
+ stopWaiting(slot);
313
+ context.df.setCustomStatus(progress);
314
+ start(owner.index);
315
+ }
316
+ } else {
317
+ stopWaiting(slot);
318
+ finish(owner.index, {
319
+ reports: [failedReport(job.inputs[owner.index], owner.index, `no result was posted back within ${String(waitMs)} ms by ${slot.handlers.join(', ')}`)]
320
+ });
321
+ }
50
322
  }
51
- const results = tasks.map(task => finished.get(task)).filter(result => result !== undefined);
323
+ const results = job.inputs.flatMap((_, index) => outcomes.get(index) ?? []);
52
324
  return {
53
325
  recipes: results.flatMap(result => result.reports),
54
326
  records: results.flatMap(result => result.records ?? []),
@@ -56,9 +328,26 @@ function crawlOrchestrator(runRecipe) {
56
328
  };
57
329
  };
58
330
  }
331
+ /** The report of a recipe the orchestration gave up on: it never finished a run, so there is nothing from the engine. */
332
+ function failedReport(input, index, error) {
333
+ const id = typeof input === 'object' && input !== null && 'id' in input && typeof input.id === 'string' ? input.id : `input-${String(index)}`;
334
+ return {
335
+ recipeId: id,
336
+ mode: 'api',
337
+ emitted: 0,
338
+ rejected: 0,
339
+ duplicates: 0,
340
+ skipped: 0,
341
+ stepsSkipped: 0,
342
+ pages: 0,
343
+ durationMs: 0,
344
+ error
345
+ };
346
+ }
59
347
 
60
348
  const KIB = 1024;
61
- const MINUTE_MS = 60_000;
349
+ const MINUTE_MS$1 = 60_000;
350
+ const HOUR_MS = 60 * MINUTE_MS$1;
62
351
  /**
63
352
  * @param options - As given.
64
353
  * @returns The options with their defaults.
@@ -69,13 +358,15 @@ function resolveHostOptions(options) {
69
358
  const authLevel = options.authLevel ?? 'function';
70
359
  const mcp = options.mcp === undefined || options.mcp === false ? undefined : {
71
360
  sampleRecords: 20,
72
- sampleMs: MINUTE_MS,
361
+ sampleMs: MINUTE_MS$1,
73
362
  ...(options.mcp !== true && options.mcp)
74
363
  };
75
364
  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');
365
+ const callouts = options.callouts === undefined ? undefined : calloutSettings(options.callouts);
76
366
  const {
77
367
  mcp: _mcp,
78
368
  canPromote,
369
+ callouts: _callouts,
79
370
  ...rest
80
371
  } = options;
81
372
  return {
@@ -83,12 +374,15 @@ function resolveHostOptions(options) {
83
374
  ...(mcp !== undefined && {
84
375
  mcp
85
376
  }),
377
+ ...(callouts !== undefined && {
378
+ callouts
379
+ }),
86
380
  canPromote: canPromote ?? (() => false),
87
381
  inlineLimitBytes: options.inlineLimitBytes ?? 256 * KIB,
88
382
  routePrefix: (options.routePrefix ?? '').replaceAll(/^\/+|\/+$/g, ''),
89
383
  authLevel,
90
384
  pools: {
91
- idleTtlMs: options.pools?.idleTtlMs ?? 10 * MINUTE_MS,
385
+ idleTtlMs: options.pools?.idleTtlMs ?? 10 * MINUTE_MS$1,
92
386
  windows: options.pools?.windows ?? {
93
387
  min: 1,
94
388
  max: 4,
@@ -96,13 +390,24 @@ function resolveHostOptions(options) {
96
390
  after: 5
97
391
  },
98
392
  idle: {
99
- afterMs: MINUTE_MS
393
+ afterMs: MINUTE_MS$1
100
394
  }
101
395
  },
102
396
  maxWindows: options.pools?.maxWindows ?? 8
103
397
  }
104
398
  };
105
399
  }
400
+ function calloutSettings(options) {
401
+ const key = process.env[options.signingKeyEnv];
402
+ if (key === undefined || key === '') throw new Error(`callouts.signingKeyEnv names ${options.signingKeyEnv}, which is not set: it must hold the key that signs resume tokens`);
403
+ return {
404
+ signingKeyEnv: options.signingKeyEnv,
405
+ waitMs: options.waitMs ?? HOUR_MS,
406
+ ...(options.publicUrl !== undefined && {
407
+ publicUrl: options.publicUrl
408
+ })
409
+ };
410
+ }
106
411
  /**
107
412
  * What a crawler for one caller is created with: the host's code and
108
413
  * secrets, and the caller's hosts.
@@ -170,30 +475,45 @@ async function packRecords(records, options) {
170
475
  };
171
476
  }
172
477
 
478
+ const MINUTE_MS = 60_000;
173
479
  /**
174
480
  * The `/crawl` activity: one input recipe, on a crawler of its own, limited
175
481
  * to the caller's hosts. A recipe that fails is reported, not thrown: the
176
482
  * orchestration still gathers the others.
177
483
  *
484
+ * When the host takes posted-back results, a hook that answers `pending` parks its call instead of
485
+ * being polled: the run stops and the result says which calls it is waiting for (`parked`), with no
486
+ * reports, because the orchestration runs the recipe again once they have arrived.
487
+ *
178
488
  * @param settings - The host settings.
179
489
  * @param task - The recipe and where its records go.
180
- * @returns Its reports (one per matrix variant) and records (or their link).
490
+ * @param now - The current time, epoch milliseconds (for the token expiry).
491
+ * @returns Its reports (one per matrix variant) and records (or their link), or what it waits for.
181
492
  */
182
- async function runRecipeTask(settings, task) {
493
+ async function runRecipeTask(settings, task, now = Date.now()) {
183
494
  const sink = memorySink();
184
495
  const crawler = createCrawler({
185
496
  ...crawlOptionsFor(settings, task.allowedHosts),
186
497
  sink,
187
498
  dedupe: task.dedupe ?? 'recipe'
188
499
  });
500
+ const parked = [];
501
+ const waiter = calloutWaiter(settings, task, parked, now);
189
502
  let reports;
190
503
  try {
191
504
  const set = await loadRecipes([task.output, task.input]);
192
- const result = await crawler.run(set);
193
- reports = result.recipes;
505
+ const run = async () => {
506
+ const result = await crawler.run(set);
507
+ return result.recipes;
508
+ };
509
+ reports = waiter === undefined ? await run() : await withCalloutWaiter(waiter, run);
194
510
  } finally {
195
511
  await crawler.close();
196
512
  }
513
+ if (parked.length > 0) return {
514
+ reports: [],
515
+ parked
516
+ };
197
517
  const packed = await packRecords(sink.records, {
198
518
  store: settings.results,
199
519
  inlineLimitBytes: settings.inlineLimitBytes,
@@ -204,6 +524,26 @@ async function runRecipeTask(settings, task) {
204
524
  ...packed
205
525
  };
206
526
  }
527
+ /** The waiter of a run, when the host takes posted-back results and the job knows where they go. */
528
+ function calloutWaiter(settings, task, parked, now) {
529
+ const callouts = settings.callouts;
530
+ if (callouts === undefined || task.callbackBase === undefined) return undefined;
531
+ const secret = process.env[callouts.signingKeyEnv] ?? '';
532
+ const base = task.callbackBase;
533
+ const expiresAt = now + callouts.waitMs + MINUTE_MS;
534
+ return {
535
+ resolutions: task.resolutions ?? {},
536
+ callbackFor: key => ({
537
+ url: callbackUrl(base, signCalloutTicket(secret, {
538
+ instanceId: task.instanceId,
539
+ index: task.index,
540
+ key,
541
+ expiresAt
542
+ }))
543
+ }),
544
+ parked
545
+ };
546
+ }
207
547
 
208
548
  /**
209
549
  * Resolves the caller of a request and the hosts its recipes may reach.
@@ -234,49 +574,6 @@ function poolKey(access, crawlId) {
234
574
  return `${access.caller ?? ''}\u{0}${crawlId}`;
235
575
  }
236
576
 
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
577
  /** A version that does not exist, or a write to one that does. */
281
578
  class RecipeStoreError extends Error {
282
579
  status;
@@ -502,6 +799,9 @@ async function startCrawl(request, client, settings, orchestration) {
502
799
  inputs: recipes.filter(recipe => recipe !== output),
503
800
  ...(parsed.data.options?.dedupe !== undefined && {
504
801
  dedupe: parsed.data.options.dedupe
802
+ }),
803
+ ...(settings.callouts !== undefined && {
804
+ callbackBase: callbackBaseOf(request.url, settings)
505
805
  })
506
806
  };
507
807
  const instanceId = await startOrchestration(client, orchestration, job);
@@ -516,7 +816,8 @@ const crawlJobSchema = z.object({
516
816
  allowedHosts: z.array(z.string()).min(1),
517
817
  output: z.unknown(),
518
818
  inputs: z.array(z.unknown()).min(1),
519
- dedupe: z.enum(['recipe', 'off']).optional()
819
+ dedupe: z.enum(['recipe', 'off']).optional(),
820
+ callbackBase: z.string().optional()
520
821
  });
521
822
  /**
522
823
  * Checks a `/crawl` orchestration's input as it comes back out of the task
@@ -1369,7 +1670,8 @@ function hostJsonWarnings(maxWindows, directory = process.cwd()) {
1369
1670
  * model): `POST /crawl` runs recipes once, `POST /jobs/{crawlId}/items`
1370
1671
  * sends one item to the caller's warm pool, `GET` and `DELETE
1371
1672
  * /jobs/{crawlId}` read and close it, `POST /recipes/{name}/{version}/promote`
1372
- * makes a draft runnable, and with `mcp` on, `/mcp` serves the authoring
1673
+ * makes a draft runnable, with `callouts` set `POST /callouts/{token}/resolve`
1674
+ * takes the results of hooks that answered `pending`, and with `mcp` on, `/mcp` serves the authoring
1373
1675
  * tools. Call it once, from the app's entry module; nothing is registered by
1374
1676
  * importing the package.
1375
1677
  *
@@ -1383,7 +1685,9 @@ function registerOpenCraw(options) {
1383
1685
  for (const warning of hostJsonWarnings(settings.pools.maxWindows)) console.warn(`[opencraw] ${warning}`);
1384
1686
  const runRecipe = async task => await runRecipeTask(settings, task);
1385
1687
  const runItem = async job => await pools.run(job);
1386
- const crawl = defineOrchestration('OpenCrawCrawl', crawlOrchestrator(defineActivity('OpenCrawRunRecipe', runRecipe)), {
1688
+ const crawl = defineOrchestration('OpenCrawCrawl', crawlOrchestrator(defineActivity('OpenCrawRunRecipe', runRecipe), {
1689
+ waitMs: settings.callouts?.waitMs
1690
+ }), {
1387
1691
  parse: parseCrawlJob
1388
1692
  });
1389
1693
  const item = defineOrchestration('OpenCrawItem', itemOrchestrator(defineActivity('OpenCrawRunItem', runItem)), {
@@ -1391,6 +1695,7 @@ function registerOpenCraw(options) {
1391
1695
  });
1392
1696
  const http = {
1393
1697
  startCrawl: async (request, client) => await startCrawl(request, client, settings, crawl),
1698
+ resolveCallout: async (request, client) => await resolveCallout(request, client, settings),
1394
1699
  submitItem: async (request, client) => await submitItem(request, client, settings, pools, item),
1395
1700
  job: async request => await jobRequest(request, settings, pools),
1396
1701
  mcp: async (request, client) => await mcpEndpoint(request, client, settings, crawl),
@@ -1421,6 +1726,13 @@ function registerOpenCraw(options) {
1421
1726
  authLevel: settings.authLevel,
1422
1727
  handler: http.promote
1423
1728
  });
1729
+ // The token in the path is the credential: the services that post results back hold no function key.
1730
+ if (settings.callouts !== undefined) df.app.client.http('OpenCrawResolveCallout', {
1731
+ route: route('callouts/{token}/resolve'),
1732
+ methods: ['POST'],
1733
+ authLevel: 'anonymous',
1734
+ handler: http.resolveCallout
1735
+ });
1424
1736
  if (settings.mcp !== undefined) df.app.client.http('OpenCrawMcp', {
1425
1737
  route: route('mcp'),
1426
1738
  methods: ['GET', 'POST', 'DELETE'],
@@ -0,0 +1,30 @@
1
+ import type { HostSettings } from '../host-options/index.js';
2
+ /**
3
+ * The name of the external event a posted-back result is raised as. The call's idempotency key is the
4
+ * same for the same call anywhere in the job, so the recipe's index keeps two recipes that make an
5
+ * identical call apart.
6
+ *
7
+ * @param index - The input recipe of the job.
8
+ * @param key - The call's idempotency key.
9
+ * @returns The event name.
10
+ */
11
+ export declare function calloutEventName(index: number, key: string): string;
12
+ /**
13
+ * Where a handler posts a result, given the URL the job was started at.
14
+ *
15
+ * `publicUrl` wins when the host sets it (behind a proxy or a custom domain the request does not show);
16
+ * otherwise it is the start URL without its last path segment, which is where `/crawl` and
17
+ * `/callouts/{token}/resolve` are siblings.
18
+ *
19
+ * @param startUrl - The URL of the `/crawl` request.
20
+ * @param settings - The host settings.
21
+ * @returns The base all callback URLs start with, without a trailing slash.
22
+ */
23
+ export declare function callbackBaseOf(startUrl: string, settings: Pick<HostSettings, 'callouts'>): string;
24
+ /**
25
+ * @param base - What `callbackBaseOf` returned.
26
+ * @param token - The signed ticket.
27
+ * @returns The URL a handler posts its `CalloutResolution` to.
28
+ */
29
+ export declare function callbackUrl(base: string, token: string): string;
30
+ //# sourceMappingURL=callout-event.mapper.d.ts.map
@@ -0,0 +1,36 @@
1
+ /** What a resume token says: which call of which recipe of which job it may settle, and until when. */
2
+ export interface CalloutTicket {
3
+ instanceId: string;
4
+ /** The input recipe of the job the call belongs to. */
5
+ index: number;
6
+ /** The call's idempotency key. */
7
+ key: string;
8
+ /** Epoch milliseconds. */
9
+ expiresAt: number;
10
+ }
11
+ export type TicketReading = {
12
+ ok: true;
13
+ ticket: CalloutTicket;
14
+ } | {
15
+ ok: false;
16
+ reason: 'malformed' | 'signature' | 'expired';
17
+ };
18
+ /**
19
+ * The token a handler posts its result back with. It is the ticket and an HMAC of it, so the host holds no
20
+ * state for it: any instance of the Function App can check it.
21
+ *
22
+ * @param secret - The host's signing key.
23
+ * @param ticket - What the token lets its holder do.
24
+ * @returns A URL-safe token.
25
+ */
26
+ export declare function signCalloutTicket(secret: string, ticket: CalloutTicket): string;
27
+ /**
28
+ * Checks a token and reads the ticket in it.
29
+ *
30
+ * @param secret - The host's signing key.
31
+ * @param token - What was posted to.
32
+ * @param now - The current time, epoch milliseconds.
33
+ * @returns The ticket, or why the token is not good.
34
+ */
35
+ export declare function readCalloutTicket(secret: string, token: string, now: number): TicketReading;
36
+ //# sourceMappingURL=callout-ticket.algorithm.d.ts.map
@@ -0,0 +1,5 @@
1
+ export { signCalloutTicket, readCalloutTicket } from './callout-ticket.algorithm.js';
2
+ export type { CalloutTicket, TicketReading } from './callout-ticket.algorithm.js';
3
+ export { calloutEventName, callbackBaseOf, callbackUrl } from './callout-event.mapper.js';
4
+ export { resolveCallout } from './resolve-callout.handler.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,17 @@
1
+ import type { HttpRequest, HttpResponseInit } from '@azure/functions';
2
+ import type { DurableClient } from 'durable-functions';
3
+ import type { HostSettings } from '../host-options/index.js';
4
+ /**
5
+ * `POST /callouts/{token}/resolve`: a handler that answered `pending` posts the result of the call here.
6
+ * The token in the path is the credential (it is a signed ticket for that one call), so the route does
7
+ * not ask for a function key. The result is raised to the waiting job as an external event; a second post
8
+ * for a call that was already settled finds nobody waiting and is refused.
9
+ *
10
+ * @param request - The request.
11
+ * @param client - The Durable client.
12
+ * @param settings - The host settings.
13
+ * @param now - The current time, epoch milliseconds.
14
+ * @returns 202 when the result was handed to the job, or the problem.
15
+ */
16
+ export declare function resolveCallout(request: HttpRequest, client: DurableClient, settings: HostSettings, now?: number): Promise<HttpResponseInit>;
17
+ //# sourceMappingURL=resolve-callout.handler.d.ts.map
@@ -1,4 +1,4 @@
1
- import type { OutputRecord, RecipeReport } from '@opencraw/core';
1
+ import type { CalloutResolution, OutputRecord, ParkedCallout, RecipeReport } from '@opencraw/core';
2
2
  /** What a `/crawl` orchestration runs: written by the starter, never by the caller. */
3
3
  export interface CrawlJob {
4
4
  caller?: string;
@@ -6,6 +6,8 @@ export interface CrawlJob {
6
6
  output: unknown;
7
7
  inputs: unknown[];
8
8
  dedupe?: 'recipe' | 'off';
9
+ /** Where handlers post results back (`https://app/api`): set by the starter when the host takes posted-back results. */
10
+ callbackBase?: string;
9
11
  }
10
12
  /** One input recipe of a job, run by one activity. */
11
13
  export interface RecipeTask {
@@ -15,12 +17,20 @@ export interface RecipeTask {
15
17
  dedupe?: 'recipe' | 'off';
16
18
  /** Where its records go when too large to return: `<instance>/<index>.jsonl`. */
17
19
  resultName: string;
20
+ /** The job's instance and this recipe's index in it: what a resume token names. */
21
+ instanceId: string;
22
+ index: number;
23
+ callbackBase?: string;
24
+ /** Results of calls that parked on an earlier run of this recipe, by idempotency key. */
25
+ resolutions?: Record<string, CalloutResolution>;
18
26
  }
19
27
  export interface RecipeTaskResult {
20
28
  /** One per run: a recipe with a `matrix` runs once per variant. */
21
29
  reports: RecipeReport[];
22
30
  records?: OutputRecord[];
23
31
  resultUrl?: string;
32
+ /** Set when calls are waiting for their results to be posted back: the run stopped, and this is what it waits for. */
33
+ parked?: ParkedCallout[];
24
34
  }
25
35
  /** What a `/crawl` orchestration returns. */
26
36
  export interface CrawlResult {
@@ -36,5 +46,7 @@ export interface CrawlProgress {
36
46
  recipes: number;
37
47
  recipesDone: number;
38
48
  records: number;
49
+ /** Recipes waiting for a posted-back result. Present only when the host takes them. */
50
+ recipesWaiting?: number;
39
51
  }
40
52
  //# sourceMappingURL=crawl-job.model.d.ts.map
@@ -1,12 +1,22 @@
1
1
  import type { TypedActivity } from '@mnci/az-durable';
2
2
  import type { OrchestrationContext, Task } from 'durable-functions';
3
3
  import type { CrawlJob, CrawlResult, RecipeTask, RecipeTaskResult } from './crawl-job.model.js';
4
+ export interface CrawlOrchestratorOptions {
5
+ /** How long a recipe waits for the results of parked calls before it fails. Only used when a call parks. */
6
+ waitMs?: number;
7
+ }
4
8
  /**
5
9
  * The `/crawl` orchestration: one activity per input recipe, all at once;
6
10
  * the custom status counts recipes and records as each one finishes.
7
11
  *
12
+ * A recipe whose hooks answered `pending` and will post their results back stops its activity with
13
+ * `parked` calls. The orchestration then waits for one external event per call (and a timer, so a
14
+ * handler that never answers fails the recipe, not the job) and runs the recipe again with the results
15
+ * so far: calls that have a result return it without being made again.
16
+ *
8
17
  * @param runRecipe - The activity that runs one recipe.
18
+ * @param options - How long to wait for posted-back results.
9
19
  * @returns The orchestrator body.
10
20
  */
11
- export declare function crawlOrchestrator(runRecipe: TypedActivity<RecipeTask, RecipeTaskResult>): (context: OrchestrationContext, job: CrawlJob) => Generator<Task, CrawlResult, unknown>;
21
+ export declare function crawlOrchestrator(runRecipe: TypedActivity<RecipeTask, RecipeTaskResult>, options?: CrawlOrchestratorOptions): (context: OrchestrationContext, job: CrawlJob) => Generator<Task, CrawlResult, unknown>;
12
22
  //# sourceMappingURL=crawl-orchestration.use-case.d.ts.map
@@ -5,9 +5,14 @@ import type { RecipeTask, RecipeTaskResult } from './crawl-job.model.js';
5
5
  * to the caller's hosts. A recipe that fails is reported, not thrown: the
6
6
  * orchestration still gathers the others.
7
7
  *
8
+ * When the host takes posted-back results, a hook that answers `pending` parks its call instead of
9
+ * being polled: the run stops and the result says which calls it is waiting for (`parked`), with no
10
+ * reports, because the orchestration runs the recipe again once they have arrived.
11
+ *
8
12
  * @param settings - The host settings.
9
13
  * @param task - The recipe and where its records go.
10
- * @returns Its reports (one per matrix variant) and records (or their link).
14
+ * @param now - The current time, epoch milliseconds (for the token expiry).
15
+ * @returns Its reports (one per matrix variant) and records (or their link), or what it waits for.
11
16
  */
12
- export declare function runRecipeTask(settings: HostSettings, task: RecipeTask): Promise<RecipeTaskResult>;
17
+ export declare function runRecipeTask(settings: HostSettings, task: RecipeTask, now?: number): Promise<RecipeTaskResult>;
13
18
  //# sourceMappingURL=run-recipe.use-case.d.ts.map
@@ -42,6 +42,19 @@ export interface OpenCrawHostOptions {
42
42
  authLevel?: 'anonymous' | 'function' | 'admin';
43
43
  /** Prepended to every route: `'opencraw'` gives `/api/opencraw/crawl`. Default none. */
44
44
  routePrefix?: string;
45
+ /**
46
+ * Lets a hook that answers `pending` post its result back (`POST /callouts/{token}/resolve`) instead of
47
+ * being asked again. Off by default: a pending hook is then polled inside the activity.
48
+ */
49
+ callouts?: CalloutSettings;
50
+ }
51
+ export interface CalloutSettings {
52
+ /** The name of the environment variable holding the key that signs resume tokens. A secret: never in code or a recipe. */
53
+ signingKeyEnv: string;
54
+ /** The base URL handlers post to (`https://app.example.com/api`), when the request URL is not it (a proxy, a custom domain). Default: derived from the request that started the job. */
55
+ publicUrl?: string;
56
+ /** How long a job waits for a posted-back result before the recipe fails. Default 1 hour. */
57
+ waitMs?: number;
45
58
  }
46
59
  export interface McpSettings {
47
60
  /** A sample run (`run` without `full`) stops after this many records per input recipe. Default 20. */
@@ -58,7 +71,7 @@ export interface PoolSettings {
58
71
  maxWindows?: number;
59
72
  }
60
73
  /** The options with their defaults. */
61
- export interface HostSettings extends Omit<OpenCrawHostOptions, 'pools' | 'inlineLimitBytes' | 'routePrefix' | 'authLevel' | 'mcp' | 'canPromote'> {
74
+ export interface HostSettings extends Omit<OpenCrawHostOptions, 'pools' | 'inlineLimitBytes' | 'routePrefix' | 'authLevel' | 'mcp' | 'canPromote' | 'callouts'> {
62
75
  inlineLimitBytes: number;
63
76
  routePrefix: string;
64
77
  authLevel: 'anonymous' | 'function' | 'admin';
@@ -66,6 +79,8 @@ export interface HostSettings extends Omit<OpenCrawHostOptions, 'pools' | 'inlin
66
79
  /** `undefined` when the endpoint is off. */
67
80
  mcp?: Required<McpSettings>;
68
81
  canPromote: (caller: string | undefined) => boolean;
82
+ /** `undefined` when posted-back results are off. */
83
+ callouts?: Required<Pick<CalloutSettings, 'signingKeyEnv' | 'waitMs'>> & Pick<CalloutSettings, 'publicUrl'>;
69
84
  }
70
85
  /**
71
86
  * @param options - As given.
@@ -1,3 +1,3 @@
1
1
  export { resolveHostOptions, crawlOptionsFor } from './host-options.config.js';
2
- export type { OpenCrawHostOptions, PoolSettings, McpSettings, HostSettings } from './host-options.config.js';
2
+ export type { OpenCrawHostOptions, PoolSettings, McpSettings, CalloutSettings, HostSettings } from './host-options.config.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,6 @@
1
1
  export { registerOpenCraw, hostJsonWarnings } from './open-craw-host/index.js';
2
2
  export type { OpenCrawHost } from './open-craw-host/index.js';
3
- export type { OpenCrawHostOptions, PoolSettings, McpSettings, HostSettings } from './host-options/index.js';
3
+ export type { OpenCrawHostOptions, PoolSettings, McpSettings, CalloutSettings, HostSettings } from './host-options/index.js';
4
4
  export { memoryRecipes, blobRecipes, RecipeStoreError } from './recipe-store/index.js';
5
5
  export type { RecipeStore, StoredRecipes, BlobRecipesOptions } from './recipe-store/index.js';
6
6
  export { blobResults } from './result-store/index.js';
@@ -19,6 +19,8 @@ export interface OpenCrawHost {
19
19
  };
20
20
  http: {
21
21
  startCrawl: (request: HttpRequest, client: DurableClient) => Promise<HttpResponse | HttpResponseInit>;
22
+ /** `POST /callouts/{token}/resolve`; registered only when `callouts` is set. */
23
+ resolveCallout: (request: HttpRequest, client: DurableClient) => Promise<HttpResponseInit>;
22
24
  submitItem: (request: HttpRequest, client: DurableClient) => Promise<HttpResponse | HttpResponseInit>;
23
25
  job: (request: HttpRequest) => Promise<HttpResponseInit>;
24
26
  /** The authoring MCP endpoint; registered only when `mcp` is on. */
@@ -33,7 +35,8 @@ export interface OpenCrawHost {
33
35
  * model): `POST /crawl` runs recipes once, `POST /jobs/{crawlId}/items`
34
36
  * sends one item to the caller's warm pool, `GET` and `DELETE
35
37
  * /jobs/{crawlId}` read and close it, `POST /recipes/{name}/{version}/promote`
36
- * makes a draft runnable, and with `mcp` on, `/mcp` serves the authoring
38
+ * makes a draft runnable, with `callouts` set `POST /callouts/{token}/resolve`
39
+ * takes the results of hooks that answered `pending`, and with `mcp` on, `/mcp` serves the authoring
37
40
  * tools. Call it once, from the app's entry module; nothing is registered by
38
41
  * importing the package.
39
42
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencraw/azure-durable",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "main": "./dist/index.esm.js",
6
6
  "module": "./dist/index.esm.js",
@@ -24,9 +24,9 @@
24
24
  "@azure/storage-blob": "^12.34.0",
25
25
  "@mnci/az-durable": "^0.1.12",
26
26
  "@modelcontextprotocol/sdk": "^1.32.1",
27
- "@opencraw/cli": "^0.1.31",
28
- "@opencraw/core": "^0.1.30",
29
- "@opencraw/mcp": "^0.1.31",
27
+ "@opencraw/cli": "^0.1.32",
28
+ "@opencraw/core": "^0.1.31",
29
+ "@opencraw/mcp": "^0.1.32",
30
30
  "durable-functions": ">=3.0.0 <4",
31
31
  "zod": "^4.6.5"
32
32
  },