slicetest 0.6.1 → 0.8.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 (51) hide show
  1. package/README.md +177 -24
  2. package/dist/cli.js +66 -2
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +13 -0
  5. package/dist/connection.d.ts +5 -0
  6. package/dist/connection.js +31 -0
  7. package/dist/db.d.ts +12 -1
  8. package/dist/db.js +33 -6
  9. package/dist/diagram.d.ts +13 -0
  10. package/dist/diagram.js +89 -0
  11. package/dist/form.d.ts +7 -2
  12. package/dist/form.js +72 -7
  13. package/dist/global-setup.d.ts +5 -0
  14. package/dist/global-setup.js +74 -9
  15. package/dist/graphql.d.ts +34 -0
  16. package/dist/graphql.js +56 -0
  17. package/dist/har.d.ts +49 -0
  18. package/dist/har.js +107 -0
  19. package/dist/http.d.ts +21 -3
  20. package/dist/http.js +47 -10
  21. package/dist/index.d.ts +5 -3
  22. package/dist/init.js +184 -15
  23. package/dist/list.d.ts +26 -0
  24. package/dist/list.js +71 -0
  25. package/dist/matchers.d.ts +16 -0
  26. package/dist/matchers.js +81 -1
  27. package/dist/openapi.d.ts +21 -0
  28. package/dist/openapi.js +32 -1
  29. package/dist/provided.d.ts +1 -0
  30. package/dist/record.d.ts +1 -1
  31. package/dist/record.js +3 -1
  32. package/dist/recording.d.ts +1 -1
  33. package/dist/recording.js +2 -2
  34. package/dist/runtime.d.ts +12 -5
  35. package/dist/runtime.js +78 -22
  36. package/dist/scenario.d.ts +14 -4
  37. package/dist/scenario.js +36 -17
  38. package/dist/schema.d.ts +5 -0
  39. package/dist/schema.js +73 -0
  40. package/dist/stub.d.ts +63 -0
  41. package/dist/stub.js +292 -5
  42. package/dist/timeline.d.ts +9 -0
  43. package/dist/timeline.js +6 -0
  44. package/dist/webhook.d.ts +16 -7
  45. package/dist/webhook.js +47 -7
  46. package/dist/yaml-runtime.d.ts +3 -2
  47. package/dist/yaml-runtime.js +308 -51
  48. package/dist/yaml.d.ts +82 -6
  49. package/dist/yaml.js +187 -21
  50. package/package.json +2 -1
  51. package/schema/scenario.schema.json +640 -14
package/dist/stub.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { isDeepStrictEqual } from "node:util";
3
+ import { describeGraphQL, graphqlErrors, graphqlOf } from "./graphql.js";
4
+ import { timeline } from "./timeline.js";
3
5
  /**
4
6
  * A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
5
7
  * `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
@@ -54,6 +56,28 @@ export class Stub {
54
56
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
55
57
  */
56
58
  on(method, path, match = {}) {
59
+ return this.#on(method, path, match);
60
+ }
61
+ /**
62
+ * Answer a GraphQL operation, whatever path the app posts it to:
63
+ * `stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data({ createIssue: { issue: { number: 1 } } })`.
64
+ * The operation is `operationName`, or the name in the document when the client sends none.
65
+ */
66
+ graphql(operation, match = {}) {
67
+ const { variables, path, ...rest } = match;
68
+ const builder = this.#on("*", path ?? /.*/, { ...rest, graphql: { operation, variables } }, `GraphQL ${operation}${path ? ` at ${path}` : ""}`);
69
+ const gql = {
70
+ ...builder,
71
+ times: (n) => (builder.times(n), gql),
72
+ once: () => (builder.once(), gql),
73
+ delay: (ms) => (builder.delay(ms), gql),
74
+ optional: () => (builder.optional(), gql),
75
+ data: (data) => builder.reply(async (call) => ({ status: 200, body: { data: typeof data === "function" ? await data(call) : data } })),
76
+ errors: (errors, data) => builder.reply({ status: 200, body: { errors: graphqlErrors(errors), ...(data === undefined ? {} : { data }) } }),
77
+ };
78
+ return gql;
79
+ }
80
+ #on(method, path, match, label) {
57
81
  const { pattern, paramNames } = compilePath(path);
58
82
  const route = {
59
83
  method: method.toUpperCase(),
@@ -64,6 +88,7 @@ export class Stub {
64
88
  remaining: Infinity,
65
89
  delayMs: 0,
66
90
  hits: 0,
91
+ label,
67
92
  };
68
93
  const add = (respond, extra = {}) => {
69
94
  this.#routes.unshift({ ...route, ...extra, respond });
@@ -73,6 +98,7 @@ export class Stub {
73
98
  times: (n) => ((route.remaining = n), builder),
74
99
  once: () => builder.times(1),
75
100
  delay: (ms) => ((route.delayMs = ms), builder),
101
+ optional: () => ((route.optional = true), builder),
76
102
  reply: (statusOrResponse, body, headers) => add(typeof statusOrResponse === "number" ? { status: statusOrResponse, body, headers } : statusOrResponse),
77
103
  replySequence: (responses) => {
78
104
  if (responses.length === 0)
@@ -162,14 +188,53 @@ export class Stub {
162
188
  unmatched() {
163
189
  return this.#calls.filter((c) => !c.matched);
164
190
  }
191
+ /** Routes registered in this scenario that no call reached, except `optional()` ones. */
192
+ unusedRoutes() {
193
+ return this.#routes.filter((r) => r.hits === 0 && !r.optional).map((r) => r.label ?? `${r.method} ${r.path}`).reverse();
194
+ }
165
195
  /** Human-readable list of registered routes, for diagnostics. */
166
196
  describeRoutes() {
167
197
  return this.#routes.map((r) => {
168
198
  const limit = Number.isFinite(r.remaining + r.hits) ? ` (${r.hits}/${r.remaining + r.hits} used)` : "";
169
- const cond = Object.keys(r.match).length ? ` + ${Object.keys(r.match).join("/")} conditions` : "";
170
- return `${r.method} ${r.path}${cond}${limit}`;
199
+ const keys = Object.keys(r.match).filter((k) => !(k === "graphql" && r.label));
200
+ const cond = keys.length ? ` + ${keys.join("/")} conditions` : "";
201
+ return `${r.label ?? `${r.method} ${r.path}`}${cond}${limit}`;
171
202
  });
172
203
  }
204
+ /**
205
+ * Why `call` wasn't answered, measured against the registered route it came closest to:
206
+ * `closest route POST /v1/charges: json.amount: expected 100, got "100"`. Undefined without routes.
207
+ */
208
+ explain(call) {
209
+ let best;
210
+ for (const route of this.#routes) {
211
+ const reasons = [];
212
+ let score = 0;
213
+ if (route.method !== "*" && route.method !== call.method) {
214
+ reasons.push(`method is ${call.method}, the route takes ${route.method}`);
215
+ score += 1;
216
+ }
217
+ if (!matchPath(route.path, route.pattern, call.path, route.paramNames)) {
218
+ reasons.push(pathHint(route.path, call.path));
219
+ score += 2 + (typeof route.path === "string" ? Math.min(distance(route.path, call.path) / 4, 3) : 1);
220
+ }
221
+ const why = conditionMismatch(route.match, call);
222
+ if (why) {
223
+ reasons.push(why);
224
+ score += 1;
225
+ }
226
+ if (reasons.length === 0 && route.remaining <= 0) {
227
+ reasons.push(`the route already answered its ${route.hits} call(s) (once() / times())`);
228
+ score += 0.5;
229
+ }
230
+ if (!best || score < best.score)
231
+ best = { route, reasons, score };
232
+ }
233
+ if (!best)
234
+ return undefined;
235
+ const label = best.route.label ?? `${best.route.method} ${best.route.path}`;
236
+ return `closest route ${label}: ${best.reasons.join("; ") || "matches now (registered after the call arrived?)"}`;
237
+ }
173
238
  reset() {
174
239
  this.#routes = [];
175
240
  this.#calls = [];
@@ -190,10 +255,13 @@ export class Stub {
190
255
  await new Promise((resolve) => this.#server.close(resolve));
191
256
  }
192
257
  async #handle(req, res) {
258
+ const start = performance.now();
193
259
  const chunks = [];
194
260
  for await (const chunk of req)
195
261
  chunks.push(chunk);
196
- const body = Buffer.concat(chunks).toString("utf8");
262
+ const raw = Buffer.concat(chunks);
263
+ const body = raw.toString("utf8");
264
+ const contentType = req.headers["content-type"] ?? "";
197
265
  const url = new URL(req.url ?? "/", this.url);
198
266
  const call = {
199
267
  method: req.method ?? "GET",
@@ -202,9 +270,13 @@ export class Stub {
202
270
  headers: req.headers,
203
271
  body,
204
272
  json: parseJson(body),
273
+ form: isForm(contentType) ? parseForm(body) : /^multipart\/form-data/i.test(contentType) ? await parseMultipart(raw, contentType) : undefined,
205
274
  params: {},
206
275
  matched: false,
207
276
  };
277
+ call.graphql = graphqlOf(call);
278
+ timeline.set(call, { start });
279
+ res.once("close", () => (timeline.get(call).end = performance.now()));
208
280
  this.#calls.push(call);
209
281
  let route;
210
282
  for (const r of this.#routes) {
@@ -236,7 +308,8 @@ export class Stub {
236
308
  }
237
309
  if (!out) {
238
310
  const hint = this.#hint ? ` (${this.#hint})` : "";
239
- res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}${hint}`);
311
+ const what = call.graphql ? `${describeGraphQL(call.graphql)} (${call.method} ${call.path})` : `${call.method} ${call.path}`;
312
+ res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${what}${hint}`);
240
313
  return;
241
314
  }
242
315
  call.matched = true;
@@ -301,9 +374,18 @@ function matchPath(path, pattern, actual, names = []) {
301
374
  if (!m)
302
375
  return undefined;
303
376
  const params = { ...m.groups };
304
- names.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1])));
377
+ names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
305
378
  return params;
306
379
  }
380
+ /** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
381
+ function decodeSegment(segment) {
382
+ try {
383
+ return decodeURIComponent(segment);
384
+ }
385
+ catch {
386
+ return segment;
387
+ }
388
+ }
307
389
  function matchConditions(match, call) {
308
390
  for (const [k, m] of Object.entries(match.query ?? {})) {
309
391
  if (!test(m, call.query.get(k) ?? undefined))
@@ -318,8 +400,131 @@ function matchConditions(match, call) {
318
400
  return false;
319
401
  if (match.json !== undefined && !subset(match.json, call.json))
320
402
  return false;
403
+ if (match.form !== undefined && !subset(formExpectation(match.form, call.form), call.form))
404
+ return false;
405
+ if (match.graphql) {
406
+ const g = call.graphql;
407
+ if (!g)
408
+ return false;
409
+ const { operation, variables } = match.graphql;
410
+ if (operation !== undefined && !(operation instanceof RegExp ? g.operation !== undefined && operation.test(g.operation) : g.operation === operation))
411
+ return false;
412
+ if (variables !== undefined && !subset(variables, g.variables))
413
+ return false;
414
+ }
321
415
  return true;
322
416
  }
417
+ /** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
418
+ function conditionMismatch(match, call) {
419
+ for (const [k, m] of Object.entries(match.query ?? {})) {
420
+ const v = call.query.get(k) ?? undefined;
421
+ if (!test(m, v))
422
+ return `query ${k}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
423
+ }
424
+ for (const [k, m] of Object.entries(match.headers ?? {})) {
425
+ const raw = call.headers[k.toLowerCase()];
426
+ const v = Array.isArray(raw) ? raw.join(", ") : raw;
427
+ if (!test(m, v))
428
+ return `header ${k.toLowerCase()}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
429
+ }
430
+ if (match.body !== undefined && !test(match.body, call.body))
431
+ return `body: expected ${show(match.body)}, got ${JSON.stringify(call.body.slice(0, 100))}`;
432
+ if (match.json !== undefined) {
433
+ if (call.json === undefined)
434
+ return `json: expected a JSON body, got ${call.body ? JSON.stringify(call.body.slice(0, 100)) : "an empty body"}`;
435
+ const diff = difference(match.json, call.json, "json");
436
+ if (diff)
437
+ return diff;
438
+ }
439
+ if (match.form !== undefined) {
440
+ if (call.form === undefined)
441
+ return `form: expected a form-encoded body, got ${call.body ? `${call.headers["content-type"] ?? "no content type"}: ${JSON.stringify(call.body.slice(0, 100))}` : "an empty body"}`;
442
+ const diff = difference(formExpectation(match.form, call.form), call.form, "form");
443
+ if (diff)
444
+ return diff;
445
+ }
446
+ if (match.graphql) {
447
+ const g = call.graphql;
448
+ if (!g)
449
+ return "graphql: the call isn't a GraphQL request";
450
+ const { operation, variables } = match.graphql;
451
+ if (operation !== undefined && !(operation instanceof RegExp ? g.operation !== undefined && operation.test(g.operation) : g.operation === operation)) {
452
+ return `graphql operation: expected ${show(operation)}, got ${g.operation ?? "an anonymous operation"}`;
453
+ }
454
+ if (variables !== undefined) {
455
+ const diff = difference(variables, g.variables, "variables");
456
+ if (diff)
457
+ return diff;
458
+ }
459
+ }
460
+ return undefined;
461
+ }
462
+ /** Where `actual` first stops containing `expected` (the rules of `subset`), as `json.items.0.sku: expected "a", got "b"`. */
463
+ function difference(expected, actual, at) {
464
+ if (subset(expected, actual))
465
+ return undefined;
466
+ if (Array.isArray(expected) && Array.isArray(actual)) {
467
+ if (expected.length !== actual.length)
468
+ return `${at}: expected ${expected.length} item(s), got ${actual.length}`;
469
+ for (const [i, e] of expected.entries()) {
470
+ const d = difference(e, actual[i], `${at}.${i}`);
471
+ if (d)
472
+ return d;
473
+ }
474
+ }
475
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && !isAsymmetric(expected) && !(expected instanceof RegExp) && actual && typeof actual === "object" && !Array.isArray(actual)) {
476
+ for (const [k, v] of Object.entries(expected)) {
477
+ if (!(k in actual))
478
+ return `${at}.${k}: expected ${show(v)}, got nothing`;
479
+ const d = difference(v, actual[k], `${at}.${k}`);
480
+ if (d)
481
+ return d;
482
+ }
483
+ }
484
+ return `${at}: expected ${show(expected)}, got ${short(actual)}`;
485
+ }
486
+ function show(m) {
487
+ if (isAsymmetric(m))
488
+ return m.toAsymmetricMatcher?.() ?? String(m);
489
+ if (m instanceof RegExp)
490
+ return String(m);
491
+ if (typeof m === "function")
492
+ return "a value the given function accepts";
493
+ return short(m);
494
+ }
495
+ function short(v) {
496
+ const s = v === undefined ? "nothing" : JSON.stringify(v);
497
+ return s.length > 80 ? `${s.slice(0, 79)}…` : s;
498
+ }
499
+ /** What is off about the path: a trailing slash, letter case, a prefix, or just a different path. */
500
+ function pathHint(expected, actual) {
501
+ if (typeof expected !== "string")
502
+ return `path ${actual} doesn't match ${expected}`;
503
+ const trim = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
504
+ if (trim(expected) === trim(actual))
505
+ return `path is ${actual}, the route is ${expected} (trailing slash)`;
506
+ if (expected.toLowerCase() === actual.toLowerCase())
507
+ return `path is ${actual}, the route is ${expected} (letter case)`;
508
+ if (actual.endsWith(expected))
509
+ return `path is ${actual}, the route is ${expected}: is the base URL's path (${actual.slice(0, -expected.length)}) in the env value?`;
510
+ if (expected.endsWith(actual))
511
+ return `path is ${actual}, the route is ${expected}: the base URL the app was given may lack ${expected.slice(0, -actual.length)}`;
512
+ return `path is ${actual}, the route is ${expected}`;
513
+ }
514
+ /** Levenshtein distance, to find the route whose path is nearest. */
515
+ function distance(a, b) {
516
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
517
+ for (let i = 1; i <= a.length; i++) {
518
+ let prev = row[0];
519
+ row[0] = i;
520
+ for (let j = 1; j <= b.length; j++) {
521
+ const tmp = row[j];
522
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
523
+ prev = tmp;
524
+ }
525
+ }
526
+ return row[b.length];
527
+ }
323
528
  function test(m, value) {
324
529
  if (isAsymmetric(m))
325
530
  return m.asymmetricMatch(value);
@@ -350,6 +555,88 @@ export function subset(expected, actual) {
350
555
  function isAsymmetric(m) {
351
556
  return !!m && typeof m === "object" && typeof m.asymmetricMatch === "function";
352
557
  }
558
+ function isForm(contentType) {
559
+ return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
560
+ }
561
+ /**
562
+ * A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
563
+ * `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
564
+ * twice (`to=1&to=2`) becomes an array too.
565
+ */
566
+ export function parseForm(body) {
567
+ const out = {};
568
+ for (const [key, value] of new URLSearchParams(body)) {
569
+ const m = /^([^[\]]+)((?:\[[^\]]*\])*)$/.exec(key);
570
+ if (!m || !m[2]) {
571
+ const prev = out[key];
572
+ out[key] = prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value];
573
+ continue;
574
+ }
575
+ const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
576
+ let node = out;
577
+ for (let i = 0; i < path.length; i++) {
578
+ const seg = path[i];
579
+ const last = i === path.length - 1;
580
+ const nextIsIndex = !last && (path[i + 1] === "" || /^\d+$/.test(path[i + 1]));
581
+ if (Array.isArray(node)) {
582
+ const index = seg === "" ? node.length : Number(seg);
583
+ if (last)
584
+ node[index] = value;
585
+ else
586
+ node = (node[index] ??= nextIsIndex ? [] : {});
587
+ }
588
+ else {
589
+ if (last)
590
+ node[seg] = value;
591
+ else {
592
+ const child = node[seg];
593
+ node = (child && typeof child === "object" ? child : (node[seg] = nextIsIndex ? [] : {}));
594
+ }
595
+ }
596
+ }
597
+ }
598
+ return out;
599
+ }
600
+ /**
601
+ * Form values arrive as strings: compare `amount: 2000` and `capture: true` with what was sent.
602
+ * Only where a string was sent, so an uploaded file's `size` stays a number.
603
+ */
604
+ function formExpectation(expected, actual) {
605
+ if ((typeof expected === "number" || typeof expected === "boolean") && typeof actual === "string")
606
+ return String(expected);
607
+ if (Array.isArray(expected))
608
+ return expected.map((e, i) => formExpectation(e, Array.isArray(actual) ? actual[i] : undefined));
609
+ if (expected && typeof expected === "object" && !isAsymmetric(expected) && !(expected instanceof RegExp)) {
610
+ const at = (k) => (actual && typeof actual === "object" ? actual[k] : undefined);
611
+ return Object.fromEntries(Object.entries(expected).map(([k, v]) => [k, formExpectation(v, at(k))]));
612
+ }
613
+ return expected;
614
+ }
615
+ const TEXTUAL = /^(text\/|application\/(json|xml|csv|x-ndjson|yaml|javascript)|[^;]*\+(json|xml))/i;
616
+ /** Fields of a `multipart/form-data` body, files as `{ filename, type, size, text }`. Undefined if it can't be parsed. */
617
+ async function parseMultipart(raw, contentType) {
618
+ let data;
619
+ try {
620
+ data = await new Response(new Uint8Array(raw), { headers: { "content-type": contentType } }).formData();
621
+ }
622
+ catch {
623
+ return undefined;
624
+ }
625
+ const out = {};
626
+ for (const [key, value] of data) {
627
+ const v = typeof value === "string"
628
+ ? value
629
+ : {
630
+ filename: value.name,
631
+ type: value.type,
632
+ size: value.size,
633
+ ...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
634
+ };
635
+ const prev = out[key];
636
+ out[key] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
637
+ }
638
+ return out;
639
+ }
353
640
  function parseJson(body) {
354
641
  if (!body)
355
642
  return undefined;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * When requests to the app and calls to stubs started and ended (`performance.now()`),
3
+ * kept beside the objects rather than on them, so that the public shapes (and snapshots
4
+ * of them) don't change. Used to put a scenario's events in order for `diagram()`.
5
+ */
6
+ export declare const timeline: WeakMap<object, {
7
+ start: number;
8
+ end?: number;
9
+ }>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * When requests to the app and calls to stubs started and ended (`performance.now()`),
3
+ * kept beside the objects rather than on them, so that the public shapes (and snapshots
4
+ * of them) don't change. Used to put a scenario's events in order for `diagram()`.
5
+ */
6
+ export const timeline = new WeakMap();
package/dist/webhook.d.ts CHANGED
@@ -8,12 +8,12 @@ export interface HmacScheme {
8
8
  /** Prepended to the digest, e.g. `sha256=`. */
9
9
  prefix?: string;
10
10
  }
11
- export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | HmacScheme;
11
+ export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | HmacScheme;
12
12
  export interface WebhookOptions {
13
13
  provider: WebhookProvider;
14
14
  /** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
15
15
  secret: string;
16
- /** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
16
+ /** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`, `X-Gitlab-Event`, `Linear-Event`, `Twitch-Eventsub-Message-Type`. */
17
17
  event?: string;
18
18
  /** Unix seconds the signature is made for. Default: now. */
19
19
  timestamp?: number;
@@ -21,20 +21,29 @@ export interface WebhookOptions {
21
21
  stale?: boolean;
22
22
  /** Signed with a different secret: the app must refuse it. */
23
23
  invalidSignature?: boolean;
24
- /** Message id for `standard` (`webhook-id`). Default: a random `msg_…`. */
24
+ /** Message id for `standard` (`webhook-id`, default a random `msg_…`) and `twitch`. */
25
25
  id?: string;
26
+ /**
27
+ * The URL the app sees the request at, for providers that sign it (Twilio). `http.webhook()` fills in
28
+ * the app's address and the path; set it when the app validates against a public URL it's configured with.
29
+ */
30
+ url?: string;
26
31
  /** Extra request headers. */
27
32
  headers?: Record<string, string>;
28
33
  }
34
+ /** The providers `signWebhook` knows by name. */
35
+ export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
29
36
  /**
30
37
  * The headers a provider sends with `body`, signed with `secret` the way its
31
38
  * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
32
- * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
33
- * Standard Webhooks (Svix, Resend, Clerk, …).
39
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
40
+ * Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
41
+ * Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
42
+ * signature and Twitch EventSub.
34
43
  */
35
44
  export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
36
- /** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
37
- export declare function webhookBody(payload: unknown): {
45
+ /** The bytes to send and their content type: objects as JSON (as a form for Twilio), URLSearchParams as a form (Slack commands), strings as-is. */
46
+ export declare function webhookBody(payload: unknown, provider?: WebhookProvider): {
38
47
  body: string;
39
48
  type: string;
40
49
  };
package/dist/webhook.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
+ import { encodeForm } from "./form.js";
3
+ /** The providers `signWebhook` knows by name. */
4
+ export const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
2
5
  const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
3
6
  /**
4
7
  * The headers a provider sends with `body`, signed with `secret` the way its
5
8
  * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
6
- * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
7
- * Standard Webhooks (Svix, Resend, Clerk, …).
9
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
10
+ * Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
11
+ * Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
12
+ * signature and Twitch EventSub.
8
13
  */
9
14
  export function signWebhook(body, opts) {
10
15
  const now = Math.floor(Date.now() / 1000);
@@ -38,15 +43,50 @@ export function signWebhook(body, opts) {
38
43
  const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
39
44
  return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
40
45
  }
46
+ case "line":
47
+ return { "x-line-signature": hmac("sha256", secret, body).digest("base64") };
48
+ case "paddle":
49
+ return { "paddle-signature": `ts=${ts};h1=${hmac("sha256", secret, `${ts}:${body}`).digest("hex")}` };
50
+ case "linear":
51
+ return {
52
+ "linear-signature": hmac("sha256", secret, body).digest("hex"),
53
+ "linear-delivery": randomUUID(),
54
+ ...(opts.event ? { "linear-event": opts.event } : {}),
55
+ };
56
+ case "gitlab":
57
+ return { "x-gitlab-token": secret, "x-gitlab-event": opts.event ?? "Push Hook", "x-gitlab-event-uuid": randomUUID() };
58
+ case "zoom":
59
+ return { "x-zm-request-timestamp": String(ts), "x-zm-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
60
+ case "twitch": {
61
+ const id = opts.id ?? randomUUID();
62
+ const at = new Date(ts * 1000).toISOString();
63
+ return {
64
+ "twitch-eventsub-message-id": id,
65
+ "twitch-eventsub-message-timestamp": at,
66
+ "twitch-eventsub-message-type": opts.event ?? "notification",
67
+ "twitch-eventsub-message-signature": `sha256=${hmac("sha256", secret, `${id}${at}${body}`).digest("hex")}`,
68
+ };
69
+ }
70
+ case "twilio": {
71
+ // Twilio's validateRequest: the URL, then each POST parameter's name and value, sorted by name; HMAC-SHA1, base64.
72
+ if (!opts.url)
73
+ throw new Error("slicetest: a twilio webhook is signed over its URL: pass `url`, or send it with http.webhook()");
74
+ const params = [...new URLSearchParams(body)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
75
+ return { "x-twilio-signature": hmac("sha1", secret, opts.url + params.map(([k, v]) => k + v).join("")).digest("base64") };
76
+ }
41
77
  default:
42
- throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
78
+ throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding })`);
43
79
  }
44
80
  }
45
- /** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
46
- export function webhookBody(payload) {
81
+ /** The bytes to send and their content type: objects as JSON (as a form for Twilio), URLSearchParams as a form (Slack commands), strings as-is. */
82
+ export function webhookBody(payload, provider) {
83
+ const form = "application/x-www-form-urlencoded";
47
84
  if (typeof payload === "string")
48
- return { body: payload, type: "application/json" };
85
+ return { body: payload, type: provider === "twilio" ? form : "application/json" };
49
86
  if (payload instanceof URLSearchParams)
50
- return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
87
+ return { body: payload.toString(), type: form };
88
+ // Twilio posts its parameters as a form.
89
+ if (provider === "twilio" && payload && typeof payload === "object")
90
+ return { body: encodeForm(payload).toString(), type: form };
51
91
  return { body: JSON.stringify(payload ?? {}), type: "application/json" };
52
92
  }
@@ -12,8 +12,9 @@ export declare function lookup(obj: unknown, path: string): unknown;
12
12
  */
13
13
  export declare function interpolate(value: unknown, vars: Vars): unknown;
14
14
  /**
15
- * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }` and
16
- * `{ $any: true }` become Vitest asymmetric matchers.
15
+ * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`, `{ $any: true }`,
16
+ * `{ $gte: 1, $lt: 10 }`, `{ $len: 3 }`, `{ $not: … }`, `{ $oneOf: [...] }` and
17
+ * `{ $format: uuid }` become Vitest asymmetric matchers. Several `$` keys must all hold.
17
18
  */
18
19
  export declare function toMatchers(value: unknown): unknown;
19
20
  export {};