@avasapp/agent-bridge 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +220 -20
  2. package/dist/adapters/ably.cjs +22 -4
  3. package/dist/adapters/ably.d.cts +8 -1
  4. package/dist/adapters/expo-router.cjs +22 -1
  5. package/dist/adapters/expo-router.d.cts +5 -1
  6. package/dist/adapters/react-native-mmkv.cjs +1 -0
  7. package/dist/adapters/react-native-mmkv.d.cts +1 -1
  8. package/dist/adapters/react-native-webview.cjs +672 -0
  9. package/dist/adapters/react-native-webview.d.cts +79 -0
  10. package/dist/adapters/socket-io.cjs +3 -1
  11. package/dist/adapters/socket-io.d.cts +1 -1
  12. package/dist/adapters/tanstack-query.cjs +256 -17
  13. package/dist/adapters/tanstack-query.d.cts +1 -1
  14. package/dist/adapters/zustand.cjs +152 -12
  15. package/dist/adapters/zustand.d.cts +19 -3
  16. package/dist/{chunk-LBYZ5JHF.js → chunk-AJD3XTNT.js} +329 -46
  17. package/dist/cli.js +848 -98
  18. package/dist/client/index.cjs +311 -47
  19. package/dist/client/index.d.ts +64 -3
  20. package/dist/client/index.js +5 -3
  21. package/dist/expo/index.d.cts +1 -1
  22. package/dist/network/index.cjs +434 -66
  23. package/dist/network/index.d.cts +53 -3
  24. package/dist/noop/index.cjs +12 -0
  25. package/dist/noop/network.cjs +7 -1
  26. package/dist/noop/react-native-webview.cjs +31 -0
  27. package/dist/realtime/index.cjs +2 -1
  28. package/dist/realtime/index.d.cts +3 -1
  29. package/dist/runtime/index.cjs +1067 -111
  30. package/dist/runtime/index.d.cts +51 -5
  31. package/dist/types-BmpykP1P.d.cts +137 -0
  32. package/entries/react-native-webview.cjs +9 -0
  33. package/package.json +18 -6
  34. package/skills/agent-bridge/SKILL.md +226 -8
  35. package/dist/types-C6DUUHnB.d.cts +0 -76
@@ -22,21 +22,108 @@ var network_exports = {};
22
22
  __export(network_exports, {
23
23
  installNetwork: () => installNetwork,
24
24
  mock: () => mock,
25
+ mockApi: () => mockApi,
25
26
  mockRequests: () => mockRequests,
26
- networkTools: () => networkTools
27
+ networkTools: () => networkTools,
28
+ strictNetwork: () => strictNetwork
27
29
  });
28
30
  module.exports = __toCommonJS(network_exports);
29
31
 
32
+ // src/shared/mock-signal.ts
33
+ var KEY = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/mock-signal");
34
+ var listeners = () => {
35
+ const g = globalThis;
36
+ return g[KEY] ?? (g[KEY] = /* @__PURE__ */ new Set());
37
+ };
38
+ var signalRequestStarted = (requestId) => {
39
+ for (const l of [...listeners()]) l.started?.(requestId);
40
+ };
41
+ var signalMockAnswered = (mockId, requestId) => {
42
+ for (const l of [...listeners()]) l.answered?.(mockId, requestId);
43
+ };
44
+ var signalMocksRemoved = (ids) => {
45
+ for (const l of [...listeners()]) l.removed?.(ids);
46
+ };
47
+
48
+ // src/network/api.ts
49
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
50
+ var ROUTE = /^(?:([A-Za-z]+)\s+)?(\/\S*)$/;
51
+ function compileRoute(baseUrl, route) {
52
+ const parsed = ROUTE.exec(route.trim());
53
+ if (!parsed)
54
+ throw new Error(`Bad route "${route}": use "METHOD /path" or "/path"`);
55
+ const [, method, path] = parsed;
56
+ const names = [];
57
+ const pattern = path.split(/(:[A-Za-z_][A-Za-z0-9_]*|\*)/).map((part) => {
58
+ if (part === "*") {
59
+ names.push("*");
60
+ return "([^?#]*)";
61
+ }
62
+ if (part.startsWith(":")) {
63
+ names.push(part.slice(1));
64
+ return "([^/?#]+)";
65
+ }
66
+ return escape(part);
67
+ }).join("");
68
+ const base = baseUrl ? escape(baseUrl.replace(/\/+$/, "")) : "(?:[a-z][a-z0-9+.-]*://[^/?#]*)?";
69
+ return {
70
+ method: method?.toUpperCase(),
71
+ regex: new RegExp(`^${base}${pattern}(?:[?#].*)?$`, "i"),
72
+ names
73
+ };
74
+ }
75
+ function queryOf(url) {
76
+ const search = /\?([^#]*)/.exec(url)?.[1];
77
+ const out = {};
78
+ if (!search) return out;
79
+ for (const pair of search.split("&")) {
80
+ if (!pair) continue;
81
+ const [key, value = ""] = pair.split("=");
82
+ const decode = (s) => {
83
+ try {
84
+ return decodeURIComponent(s.replace(/\+/g, " "));
85
+ } catch {
86
+ return s;
87
+ }
88
+ };
89
+ out[decode(key)] = decode(value);
90
+ }
91
+ return out;
92
+ }
93
+ function apiRoutes(baseUrl, routes, options = {}) {
94
+ const { id, ...mockOptions } = options;
95
+ return Object.entries(routes).map(([route, response]) => {
96
+ const { method, regex, names } = compileRoute(baseUrl, route);
97
+ const match = { url: regex, method };
98
+ const handler = typeof response === "function" ? (request) => {
99
+ const values = regex.exec(request.url)?.slice(1) ?? [];
100
+ const params = Object.fromEntries(
101
+ names.map((name, i) => [name, decodeURIComponent(values[i] ?? "")])
102
+ );
103
+ return response({ ...request, params, query: queryOf(request.url) });
104
+ } : response;
105
+ return {
106
+ match,
107
+ response: handler,
108
+ options: { ...mockOptions, ...id && { id: `${id} ${route}` } }
109
+ };
110
+ });
111
+ }
112
+
30
113
  // src/network/body.ts
31
114
  var MAX_BODY = 2048;
32
- function formatBody(text) {
115
+ var MAX_FULL_BODY = 1e6;
116
+ var FULL_BUDGET = 4e6;
117
+ function compactBody(text) {
33
118
  if (text === void 0 || text === "") return void 0;
34
- let out = text;
35
119
  try {
36
- out = JSON.stringify(JSON.parse(text));
120
+ return JSON.stringify(JSON.parse(text));
37
121
  } catch {
122
+ return text;
38
123
  }
39
- return out.length > MAX_BODY ? `${out.slice(0, MAX_BODY)}\u2026 (+${out.length - MAX_BODY} chars)` : out;
124
+ }
125
+ function truncateBody(text) {
126
+ return text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}\u2026 (+${text.length - MAX_BODY} chars)` : text;
40
127
  }
41
128
  function bodyText(body) {
42
129
  if (body === void 0 || body === null) return void 0;
@@ -62,10 +149,10 @@ function errorMessage(error) {
62
149
 
63
150
  // src/network/state.ts
64
151
  var LOG_SIZE = 100;
65
- var KEY = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/network");
152
+ var KEY2 = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/network");
66
153
  function networkState() {
67
154
  const g = globalThis;
68
- g[KEY] ?? (g[KEY] = {
155
+ g[KEY2] ?? (g[KEY2] = {
69
156
  installed: false,
70
157
  log: [],
71
158
  nextLogId: 1,
@@ -74,9 +161,12 @@ function networkState() {
74
161
  insideFetch: 0,
75
162
  devHost: null,
76
163
  skip: [],
77
- uninstall: []
164
+ uninstall: [],
165
+ strictApp: [],
166
+ strictAgent: null,
167
+ blocked: /* @__PURE__ */ new Map()
78
168
  });
79
- return g[KEY];
169
+ return g[KEY2];
80
170
  }
81
171
  var URL_PARTS = /^(?:[a-z][a-z0-9+.-]*:)?(?:\/\/([^/?#]*))?([^?#]*)/i;
82
172
  var hostOf = (url) => URL_PARTS.exec(url)?.[1] ?? "";
@@ -100,29 +190,96 @@ function deriveDevHost() {
100
190
  return null;
101
191
  }
102
192
  }
193
+ var FULL = /* @__PURE__ */ new WeakMap();
194
+ var fullOf = (entry) => {
195
+ let full = FULL.get(entry);
196
+ if (!full) FULL.set(entry, full = {});
197
+ return full;
198
+ };
199
+ function enforceBudget() {
200
+ const { log } = networkState();
201
+ const size = (f) => (f?.requestBody?.length ?? 0) + (f?.responseBody?.length ?? 0);
202
+ let total = log.reduce((sum, e) => sum + size(FULL.get(e)), 0);
203
+ for (const entry of log) {
204
+ if (total <= FULL_BUDGET) return;
205
+ const full = FULL.get(entry);
206
+ if (!full || !size(full)) continue;
207
+ total -= size(full);
208
+ delete full.requestBody;
209
+ if (full.responseBody !== void 0) {
210
+ delete full.responseBody;
211
+ full.response = "evicted";
212
+ }
213
+ }
214
+ }
215
+ function keepRequestBody(entry, text) {
216
+ const body = compactBody(text);
217
+ if (body === void 0) return;
218
+ entry.requestBody = truncateBody(body);
219
+ if (text.length <= MAX_FULL_BODY) {
220
+ fullOf(entry).requestBody = text;
221
+ enforceBudget();
222
+ }
223
+ }
224
+ function setResponseBody(entry, text, contentType) {
225
+ const full = fullOf(entry);
226
+ if (contentType !== void 0) full.contentType = contentType;
227
+ if (text === void 0) {
228
+ full.response = "none";
229
+ return;
230
+ }
231
+ const body = compactBody(text);
232
+ if (body !== void 0) entry.responseBody = truncateBody(body);
233
+ if (text.length > MAX_FULL_BODY) {
234
+ full.response = "cut";
235
+ return;
236
+ }
237
+ full.responseBody = text;
238
+ full.response = "kept";
239
+ enforceBudget();
240
+ }
241
+ function markResponse(entry, response) {
242
+ fullOf(entry).response = response;
243
+ }
103
244
  function startEntry(state, fields) {
104
245
  const entry = {
105
246
  id: state.nextLogId++,
106
- ...fields,
247
+ method: fields.method,
248
+ url: fields.url,
249
+ startedAt: Date.now(),
107
250
  ms: 0,
108
- pending: true,
109
- started: Date.now()
251
+ pending: true
110
252
  };
111
- if (entry.requestBody === void 0) delete entry.requestBody;
253
+ keepRequestBody(entry, fields.requestBody);
112
254
  state.log.push(entry);
255
+ signalRequestStarted(entry.id);
113
256
  if (state.log.length > LOG_SIZE)
114
257
  state.log.splice(0, state.log.length - LOG_SIZE);
115
258
  return entry;
116
259
  }
117
260
  function finishEntry(entry, fields) {
118
- entry.ms = Date.now() - entry.started;
261
+ entry.ms = Date.now() - entry.startedAt;
119
262
  delete entry.pending;
120
- for (const [key, value] of Object.entries(fields))
263
+ const { responseBody, contentType, ...rest } = fields;
264
+ for (const [key, value] of Object.entries(rest))
121
265
  if (value !== void 0) entry[key] = value;
266
+ if (responseBody !== void 0) setResponseBody(entry, responseBody, contentType);
267
+ }
268
+ function publicEntry(entry, full = false) {
269
+ const out = entry.pending ? { ...entry, ms: Date.now() - entry.startedAt } : { ...entry };
270
+ if (full) {
271
+ const { requestBody, responseBody } = FULL.get(entry) ?? {};
272
+ if (requestBody !== void 0) out.requestBody = requestBody;
273
+ if (responseBody !== void 0) out.responseBody = responseBody;
274
+ const cut = entry.responseBody !== void 0 && responseBody === void 0;
275
+ const cutRequest = entry.requestBody !== void 0 && requestBody === void 0;
276
+ if (cut || cutRequest) out.truncated = true;
277
+ }
278
+ return out;
122
279
  }
123
- function publicEntry(entry) {
124
- const { started, ...rest } = entry;
125
- return rest.pending && started !== void 0 ? { ...rest, ms: Date.now() - started } : rest;
280
+ function fullResponse(entry) {
281
+ const full = FULL.get(entry);
282
+ return { state: full?.response ?? "none", body: full?.responseBody, contentType: full?.contentType };
126
283
  }
127
284
  function urlTest(url) {
128
285
  if (typeof url === "string") return (u) => u.includes(url);
@@ -143,6 +300,8 @@ function compileMatch(match) {
143
300
  function addMock(state, source, match, response, options = {}) {
144
301
  if (!response || typeof response !== "object" && typeof response !== "function")
145
302
  throw new Error("A mock needs a response: { status?, json?, body?, headers? }, { offline: true } or a handler");
303
+ if (options.priority !== void 0 && !Number.isFinite(options.priority))
304
+ throw new Error("priority must be a finite number");
146
305
  const seq = state.nextSeq++;
147
306
  const id = options.id ?? `${source}-${seq}`;
148
307
  removeMocks(state, (m) => m.id === id);
@@ -153,6 +312,7 @@ function addMock(state, source, match, response, options = {}) {
153
312
  response,
154
313
  times: options.times,
155
314
  delayMs: options.delayMs ?? 0,
315
+ priority: options.priority ?? 0,
156
316
  hits: 0,
157
317
  seq,
158
318
  test: compileMatch(match)
@@ -165,7 +325,21 @@ function removeMocks(state, which) {
165
325
  state.mocks = state.mocks.filter((m) => !which(m));
166
326
  return before - state.mocks.length;
167
327
  }
168
- var mockOrder = (a, b) => Number(b.source === "agent") - Number(a.source === "agent") || b.seq - a.seq;
328
+ var mockOrder = (a, b) => Number(b.source === "agent") - Number(a.source === "agent") || b.priority - a.priority || b.seq - a.seq;
329
+ var substringMatch = (match) => {
330
+ const spec = typeof match === "string" ? { url: match } : match;
331
+ return typeof spec.url === "string" ? { url: spec.url, method: spec.method?.toUpperCase() } : null;
332
+ };
333
+ function shadowedMocks(state, added) {
334
+ const next = substringMatch(added.match);
335
+ if (!next || typeof added.response === "function") return [];
336
+ return state.mocks.filter((old) => {
337
+ if (old === added || old.source !== "agent" || mockOrder(added, old) >= 0)
338
+ return false;
339
+ const prev = substringMatch(old.match);
340
+ return !!prev && prev.url.includes(next.url) && (!next.method || next.method === prev.method);
341
+ });
342
+ }
169
343
  function matchingMocks(state, method, url) {
170
344
  return state.mocks.filter((m) => m.test(method, url)).sort(mockOrder);
171
345
  }
@@ -196,6 +370,7 @@ function mockRequest(method, url, headers, body) {
196
370
  }
197
371
  };
198
372
  }
373
+ var contentTypeOf = (headers) => Object.entries(headers).find(([k]) => k.toLowerCase() === "content-type")?.[1];
199
374
  function responseParts(response) {
200
375
  const r = response;
201
376
  const status = r.status ?? 200;
@@ -207,6 +382,41 @@ function responseParts(response) {
207
382
  };
208
383
  return { status, text: r.body ?? "", headers: { ...r.headers } };
209
384
  }
385
+ function strictRule(options = {}) {
386
+ const allow = options.allow ?? [];
387
+ if (!Array.isArray(allow))
388
+ throw new Error("allow must be a list of URL substrings or regexes");
389
+ return {
390
+ allow: allow.map((rule) => urlTest(rule)),
391
+ allowShown: allow.map(
392
+ (rule) => typeof rule === "string" ? rule : String(rule instanceof RegExp ? rule : new RegExp(rule.regex, rule.flags))
393
+ ),
394
+ status: options.status ?? 501,
395
+ offline: options.offline === true
396
+ };
397
+ }
398
+ function strictRules(state) {
399
+ if (state.strictAgent === false) return [];
400
+ return state.strictAgent ? [state.strictAgent, ...state.strictApp] : state.strictApp;
401
+ }
402
+ var BLOCKED_HEADER = "x-agent-bridge";
403
+ function blockedResponse(state, method, url) {
404
+ const rules = strictRules(state);
405
+ if (!rules.length || rules.some((r) => r.allow.some((test) => test(url))))
406
+ return null;
407
+ const key = `${method} ${url}`;
408
+ const seen = state.blocked.get(key);
409
+ if (seen) seen.count += 1;
410
+ else state.blocked.set(key, { method, url, count: 1 });
411
+ const error = `agent-bridge strict network: no mock for ${key}`;
412
+ console.error(`${error}. Mock it, or allow it in strict mode.`);
413
+ if (rules[0].offline) return { offline: true };
414
+ return {
415
+ status: rules[0].status,
416
+ json: { error },
417
+ headers: { [BLOCKED_HEADER]: "blocked" }
418
+ };
419
+ }
210
420
  var isOffline = (response) => response.offline === true;
211
421
  var OFFLINE_MESSAGE = "Network request failed";
212
422
  var sleep = (ms) => ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
@@ -273,7 +483,7 @@ function patchFetch(state) {
273
483
  const entry = startEntry(state, {
274
484
  method,
275
485
  url,
276
- requestBody: formatBody(requestBody)
486
+ requestBody
277
487
  });
278
488
  const real = () => {
279
489
  let promise;
@@ -286,14 +496,11 @@ function patchFetch(state) {
286
496
  return promise.then(
287
497
  (res) => {
288
498
  finishEntry(entry, { status: res.status });
289
- const type = res.headers?.get?.("content-type");
499
+ const type = res.headers?.get?.("content-type") ?? void 0;
290
500
  if (isTextual(type) && typeof res.clone === "function") {
291
- res.clone().text().then((text) => {
292
- const body = formatBody(text);
293
- if (body !== void 0) entry.responseBody = body;
294
- }).catch(() => {
295
- });
296
- }
501
+ markResponse(entry, "pending");
502
+ res.clone().text().then((text) => setResponseBody(entry, text, type)).catch(() => markResponse(entry, "none"));
503
+ } else markResponse(entry, "none");
297
504
  return res;
298
505
  },
299
506
  (error) => {
@@ -302,8 +509,20 @@ function patchFetch(state) {
302
509
  }
303
510
  );
304
511
  };
512
+ const passOn = () => {
513
+ const blocked = blockedResponse(state, method, url);
514
+ if (!blocked) return real();
515
+ entry.blocked = true;
516
+ if (isOffline(blocked)) {
517
+ finishEntry(entry, { error: `${OFFLINE_MESSAGE} (strict network)` });
518
+ return Promise.reject(new TypeError(OFFLINE_MESSAGE));
519
+ }
520
+ const { status, text, headers } = responseParts(blocked);
521
+ finishEntry(entry, { status, responseBody: text, contentType: contentTypeOf(headers) });
522
+ return Promise.resolve(mockedResponse(blocked));
523
+ };
305
524
  const candidates = matchingMocks(state, method, url);
306
- if (!candidates.length) return real();
525
+ if (!candidates.length) return passOn();
307
526
  const request = async () => {
308
527
  let body = requestBody;
309
528
  if (body === void 0 && init?.body === void 0 && req.clone) {
@@ -322,8 +541,9 @@ function patchFetch(state) {
322
541
  throw error;
323
542
  });
324
543
  return answered.then(async (hit) => {
325
- if (!hit) return real();
544
+ if (!hit) return passOn();
326
545
  entry.mocked = true;
546
+ if (hit.mock.source === "agent") signalMockAnswered(hit.mock.id, entry.id);
327
547
  await sleep(hit.mock.delayMs);
328
548
  if (init?.signal?.aborted) {
329
549
  finishEntry(entry, { error: "aborted" });
@@ -333,8 +553,8 @@ function patchFetch(state) {
333
553
  finishEntry(entry, { error: `${OFFLINE_MESSAGE} (mocked offline)` });
334
554
  throw new TypeError(OFFLINE_MESSAGE);
335
555
  }
336
- const { status, text } = responseParts(hit.response);
337
- finishEntry(entry, { status, responseBody: formatBody(text) });
556
+ const { status, text, headers } = responseParts(hit.response);
557
+ finishEntry(entry, { status, responseBody: text, contentType: contentTypeOf(headers) });
338
558
  return mockedResponse(hit.response);
339
559
  });
340
560
  };
@@ -361,11 +581,14 @@ function track(xhr, entry) {
361
581
  xhr.addEventListener("timeout", () => error = "timed out");
362
582
  xhr.addEventListener("abort", () => error = "aborted");
363
583
  xhr.addEventListener("loadend", () => {
584
+ const text = error ? void 0 : responseTextOf(xhr);
364
585
  finishEntry(entry, {
365
586
  status: error ? void 0 : xhr.status,
366
587
  error,
367
- responseBody: error ? void 0 : formatBody(responseTextOf(xhr))
588
+ responseBody: text,
589
+ contentType: xhr.getResponseHeader?.("content-type") ?? void 0
368
590
  });
591
+ if (!error && text === void 0) markResponse(entry, "none");
369
592
  });
370
593
  }
371
594
  var fakeRequestId = 0;
@@ -373,9 +596,9 @@ function fire(xhr, type) {
373
596
  const Ctor = (type === "readystatechange" ? void 0 : globalThis.ProgressEvent) ?? globalThis.Event;
374
597
  xhr.dispatchEvent(new Ctor(type));
375
598
  }
376
- function respond(xhr, hit, url) {
377
- const offline = isOffline(hit.response);
378
- const { status, text, headers } = responseParts(hit.response);
599
+ function respond(xhr, mocked, url) {
600
+ const offline = isOffline(mocked);
601
+ const { status, text, headers } = responseParts(mocked);
379
602
  if (isRN(xhr)) {
380
603
  const id = -++fakeRequestId;
381
604
  xhr.__didCreateRequest(id);
@@ -452,38 +675,49 @@ function patchXhr(state) {
452
675
  const entry = startEntry(state, {
453
676
  method,
454
677
  url,
455
- requestBody: formatBody(text)
678
+ requestBody: text
456
679
  });
680
+ const passOn = () => {
681
+ const blocked = blockedResponse(state, method, url);
682
+ if (!blocked) {
683
+ track(this, entry);
684
+ return send.call(this, body);
685
+ }
686
+ const { status, text: out, headers } = responseParts(blocked);
687
+ entry.blocked = true;
688
+ finishEntry(
689
+ entry,
690
+ isOffline(blocked) ? { error: `${OFFLINE_MESSAGE} (strict network)` } : { status, responseBody: out, contentType: contentTypeOf(headers) }
691
+ );
692
+ void Promise.resolve().then(() => {
693
+ if (!meta.aborted) respond(this, blocked, url);
694
+ });
695
+ };
457
696
  const candidates = matchingMocks(state, method, url);
458
- if (!candidates.length) {
459
- track(this, entry);
460
- return send.call(this, body);
461
- }
697
+ if (!candidates.length) return passOn();
462
698
  const request = async () => mockRequest(method, url, meta.headers, text);
463
699
  answer(state, candidates, request).then(
464
700
  async (hit) => {
465
- if (!hit) {
466
- track(this, entry);
467
- return send.call(this, body);
468
- }
701
+ if (!hit) return passOn();
469
702
  entry.mocked = true;
703
+ if (hit.mock.source === "agent") signalMockAnswered(hit.mock.id, entry.id);
470
704
  await sleep(hit.mock.delayMs);
471
705
  if (meta.aborted) {
472
706
  finishEntry(entry, { error: "aborted" });
473
707
  return;
474
708
  }
475
709
  const offline = isOffline(hit.response);
476
- const { status, text: out } = responseParts(hit.response);
710
+ const { status, text: out, headers } = responseParts(hit.response);
477
711
  finishEntry(
478
712
  entry,
479
- offline ? { error: `${OFFLINE_MESSAGE} (mocked offline)` } : { status, responseBody: formatBody(out) }
713
+ offline ? { error: `${OFFLINE_MESSAGE} (mocked offline)` } : { status, responseBody: out, contentType: contentTypeOf(headers) }
480
714
  );
481
- respond(this, hit, url);
715
+ respond(this, hit.response, url);
482
716
  },
483
717
  (error) => {
484
718
  entry.mocked = true;
485
719
  finishEntry(entry, { error: errorMessage(error) });
486
- respond(this, { mock: candidates[0], response: { offline: true } }, url);
720
+ respond(this, { offline: true }, url);
487
721
  }
488
722
  );
489
723
  };
@@ -513,56 +747,190 @@ function mockRequests(routes) {
513
747
  for (const m of added) m.remove();
514
748
  };
515
749
  }
516
- var describeMock = (m) => ({
750
+ function mockApi(baseUrl, routes, options) {
751
+ return mockRequests(apiRoutes(baseUrl, routes, options));
752
+ }
753
+ function strictNetwork(options = {}) {
754
+ installNetwork();
755
+ const state = networkState();
756
+ const rule = strictRule(options);
757
+ state.strictApp.push(rule);
758
+ return () => {
759
+ state.strictApp = state.strictApp.filter((r) => r !== rule);
760
+ };
761
+ }
762
+ var escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
763
+ var isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
764
+ function deepMerge(base, patch) {
765
+ if (!isPlain(base) || !isPlain(patch)) return patch;
766
+ const out = { ...base };
767
+ for (const [key, value] of Object.entries(patch)) out[key] = deepMerge(base[key], value);
768
+ return out;
769
+ }
770
+ var describeMatch = (match) => {
771
+ if (match instanceof RegExp) return String(match);
772
+ if (typeof match === "object" && match.url instanceof RegExp)
773
+ return { ...match, url: String(match.url) };
774
+ return match;
775
+ };
776
+ function describeResponse(response, full) {
777
+ if (typeof response === "function") return { response: "[handler]" };
778
+ if (full || "offline" in response) return { response };
779
+ const out = { ...response };
780
+ let truncated = false;
781
+ if (response.json !== void 0) {
782
+ const text = JSON.stringify(response.json) ?? "";
783
+ const cut = truncateBody(text);
784
+ if (cut !== text) {
785
+ out.json = cut;
786
+ truncated = true;
787
+ }
788
+ }
789
+ if (typeof response.body === "string") {
790
+ const cut = truncateBody(response.body);
791
+ if (cut !== response.body) {
792
+ out.body = cut;
793
+ truncated = true;
794
+ }
795
+ }
796
+ return truncated ? { response: out, truncated } : { response };
797
+ }
798
+ var describeMock = (m, full) => ({
517
799
  id: m.id,
518
800
  source: m.source,
519
- match: m.match instanceof RegExp ? String(m.match) : m.match,
520
- response: typeof m.response === "function" ? "[handler]" : m.response,
801
+ match: describeMatch(m.match),
802
+ ...describeResponse(m.response, full),
521
803
  times: m.times,
522
804
  hits: m.hits,
523
- delayMs: m.delayMs || void 0
805
+ delayMs: m.delayMs || void 0,
806
+ priority: m.priority || void 0
524
807
  });
525
808
  function networkTools(options = {}) {
526
809
  installNetwork();
527
810
  const state = networkState();
528
811
  if (options.skip) state.skip = options.skip;
529
812
  const isAgent = (m) => m.source === "agent";
813
+ const logged = (id) => {
814
+ const entry = state.log.find((e) => e.id === id);
815
+ if (!entry) throw new Error(`No logged request with id ${id}; it may have left the log`);
816
+ return entry;
817
+ };
818
+ const addAgentMock = (match, response, { times, delayMs, priority }) => {
819
+ const added = addMock(state, "agent", match, response, { times, delayMs, priority });
820
+ const shadows = shadowedMocks(state, added).map((m) => m.id);
821
+ return shadows.length ? { id: added.id, shadows } : { id: added.id };
822
+ };
530
823
  return {
531
824
  "net.log": {
532
- description: "Recent requests, newest first: method, url, status, ms, bodies (~2 KB), error, mocked. Filter by url substring or method; clear: true empties the log after reading.",
825
+ description: 'Recent requests, newest first: method, url, startedAt (epoch ms), status, ms, bodies (~2 KB, cut with "\u2026 (+N chars)"), error, mocked. Filter by url substring, method, since (epoch ms: startedAt >= since) or sinceId (id > sinceId); full: true returns whole bodies (truncated: true when one is too large or gone); clear: true empties the log after reading. net.entry gets one request whole.',
533
826
  run: (filter = {}) => {
534
827
  const method = filter.method?.toUpperCase();
828
+ if (filter.since !== void 0 && !(filter.since >= 1e11))
829
+ throw new Error("since is an epoch time in ms (e.g. Date.now()); use sinceId for log ids");
535
830
  const entries = state.log.filter(
536
- (e) => (!filter.url || e.url.includes(filter.url)) && (!method || e.method === method)
537
- ).reverse().slice(0, filter.limit ?? 20).map(publicEntry);
831
+ (e) => (!filter.url || e.url.includes(filter.url)) && (!method || e.method === method) && (filter.since === void 0 || e.startedAt >= filter.since) && (filter.sinceId === void 0 || e.id > filter.sinceId)
832
+ ).reverse().slice(0, filter.limit ?? 20).map((e) => publicEntry(e, filter.full));
538
833
  if (filter.clear) state.log.length = 0;
539
834
  return entries;
540
835
  }
541
836
  },
837
+ "net.entry": {
838
+ description: "One logged request by id, with its request and response bodies whole (not cut at ~2 KB). Ids come from net.log.",
839
+ run: (id) => publicEntry(logged(id), true)
840
+ },
542
841
  "net.mock": {
543
- description: 'Answer matching requests with a canned response until unmocked. match: "/path" or { url: string | { regex }, method? }. response: { status?, json?, body?, headers? } or { offline: true }. options: { times?, delayMs? }.',
842
+ description: 'Answer matching requests with a canned response until unmocked. match: "/path" or { url: string | { regex }, method? }. response: { status?, json?, body?, headers? } or { offline: true }. options: { times?, delayMs?, priority? }. The newest mock is tried first, so a later broad mock shadows an earlier specific one; give the specific one a higher priority (default 0, ties newest first; negative makes a fallback); agent mocks still come before the app mocks. The result lists `shadows` when the new mock will be tried before an agent mock it also matches.',
544
843
  run: (match, response, mockOptions = {}) => {
545
844
  if (typeof response !== "object" || response === null)
546
845
  throw new Error("response must be an object, e.g. { status: 500 }");
547
- const { times, delayMs } = mockOptions;
548
- return { id: addMock(state, "agent", match, response, { times, delayMs }).id };
846
+ return addAgentMock(match, response, mockOptions);
847
+ }
848
+ },
849
+ "net.mockFromLog": {
850
+ description: "Mock the same method and URL as a logged request (id from net.log) with its status and whole response body, optionally changed by patch: JSON objects merge deeply, other values replace. [id, patch?, options?] options as net.mock. Returns { id, match, response }.",
851
+ run: (id, patch, mockOptions = {}) => {
852
+ const entry = logged(id);
853
+ if (entry.status === void 0)
854
+ throw new Error(`Request ${id} has no response to copy (${entry.error ?? "still pending"})`);
855
+ const { state: kept, body, contentType } = fullResponse(entry);
856
+ if (kept !== "kept" || body === void 0)
857
+ throw new Error(
858
+ `Request ${id}'s response body can't be copied: ${{
859
+ pending: "it is still being read, try again",
860
+ none: "it was not captured (binary or unreadable)",
861
+ cut: "it is too large",
862
+ evicted: "it was dropped to save memory",
863
+ kept: "it is missing"
864
+ }[kept]}`
865
+ );
866
+ const response = { status: entry.status };
867
+ if (patch !== void 0) {
868
+ let json;
869
+ try {
870
+ json = JSON.parse(body);
871
+ } catch {
872
+ throw new Error(`Request ${id}'s response is not JSON, so it can't take a patch`);
873
+ }
874
+ response.json = deepMerge(json, patch);
875
+ } else {
876
+ response.body = body;
877
+ if (contentType) response.headers = { "content-type": contentType };
878
+ }
879
+ const match = { url: { regex: `^${escapeRegex(entry.url)}$` }, method: entry.method };
880
+ return { ...addAgentMock(match, response, mockOptions), match: { url: entry.url, method: entry.method }, response };
549
881
  }
550
882
  },
551
883
  "net.mocks": {
552
- description: "Active mocks, agent and app, in the order they are tried.",
553
- run: () => [...state.mocks].sort(mockOrder).map(describeMock)
884
+ description: 'Active mocks, agent and app, in the order they are tried. Response json and body over ~2 KB are cut like net.log (json shown as its cut JSON text, "\u2026 (+N chars)"), with truncated: true; { full: true } returns them whole.',
885
+ run: (options2 = {}) => [...state.mocks].sort(mockOrder).map((m) => describeMock(m, options2.full))
554
886
  },
555
887
  "net.unmock": {
556
- description: "Remove one agent mock by id, or every agent mock when no id. Returns how many.",
557
- run: (id) => removeMocks(state, (m) => isAgent(m) && (id === void 0 || m.id === id))
888
+ description: "Remove one agent mock by id, or every agent mock when no id. Returns how many. With the query adapter, queries that fetched while the mock answered are reset, so its fake data can't outlive it.",
889
+ run: (id) => {
890
+ const removed = state.mocks.filter(
891
+ (m) => isAgent(m) && (id === void 0 || m.id === id)
892
+ );
893
+ const count = removeMocks(state, (m) => removed.includes(m));
894
+ signalMocksRemoved(id === void 0 ? void 0 : [id]);
895
+ return count;
896
+ }
897
+ },
898
+ "net.strict": {
899
+ description: "Strict mode: requests no mock answers fail with a 501 (or like offline) instead of reaching a server. [true | false | { allow?: [substring | { regex }], status?, offline? }] sets it for the agent, [null] follows the app again; no args reads it. Returns { strict, source, allow, blocked }.",
900
+ run: (...args) => {
901
+ if (args.length) {
902
+ const value = args[0];
903
+ if (value === null || value === void 0) state.strictAgent = null;
904
+ else if (value === false) state.strictAgent = false;
905
+ else if (value === true) state.strictAgent = strictRule();
906
+ else if (typeof value === "object") state.strictAgent = strictRule(value);
907
+ else throw new Error("Pass true, false, null or { allow?, status?, offline? }");
908
+ }
909
+ const rules = strictRules(state);
910
+ return {
911
+ strict: rules.length > 0,
912
+ source: state.strictAgent !== null ? "agent" : rules.length ? "app" : null,
913
+ allow: rules.flatMap((r) => r.allowShown),
914
+ blocked: [...state.blocked.values()].slice(-20).reverse()
915
+ };
916
+ }
558
917
  },
559
918
  "net.clear": {
560
- description: "Empty the request log. Returns how many entries it held.",
561
- run: () => state.log.splice(0).length
919
+ description: "Empty the request log and the strict-mode blocked list. Returns how many log entries it held.",
920
+ run: () => {
921
+ state.blocked.clear();
922
+ return state.log.splice(0).length;
923
+ }
562
924
  },
563
925
  "net.restore": {
564
- description: "Undo the agent's network changes: remove agent mocks, keep the app's. Returns how many were removed.",
565
- run: () => removeMocks(state, isAgent)
926
+ pending: () => state.strictAgent !== null || state.mocks.some(isAgent),
927
+ description: "Undo the agent's network changes: remove agent mocks and its net.strict setting, keep the app's. Returns how many mocks were removed. With the query adapter, queries that fetched while an agent mock answered are reset (query.restore reports how many).",
928
+ run: () => {
929
+ state.strictAgent = null;
930
+ const count = removeMocks(state, isAgent);
931
+ signalMocksRemoved();
932
+ return count;
933
+ }
566
934
  }
567
935
  };
568
936
  }