@benclmnt/postmock 0.0.3 → 0.0.4

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.
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import type { PostmockConfig } from "./server.ts";
3
3
  /** Every setting is one of these env keys. Plugins read their own keys when they start. */
4
- export declare const ENV_KEYS: readonly ["POSTMOCK_HOST", "POSTMOCK_API_PORT", "POSTMOCK_CONTROL_PORT", "POSTMOCK_SEED", "POSTMOCK_HTTPS_PORT", "POSTMOCK_HTTPS_TLS_KEY", "POSTMOCK_HTTPS_TLS_CERT", "POSTMOCK_SMTP_PORTS", "POSTMOCK_SMTP_TLS_KEY", "POSTMOCK_SMTP_TLS_CERT", "POSTMOCK_WEBHOOKS_ALLOW_HOSTS"];
4
+ export declare const ENV_KEYS: readonly ["POSTMOCK_HOST", "POSTMOCK_API_PORT", "POSTMOCK_CONTROL_PORT", "POSTMOCK_SEED", "POSTMOCK_CLOCK", "POSTMOCK_HTTPS_PORT", "POSTMOCK_HTTPS_TLS_KEY", "POSTMOCK_HTTPS_TLS_CERT", "POSTMOCK_SMTP_PORTS", "POSTMOCK_SMTP_TLS_KEY", "POSTMOCK_SMTP_TLS_CERT", "POSTMOCK_WEBHOOKS_ALLOW_HOSTS"];
5
5
  export declare const flagOf: (key: string) => string;
6
6
  export declare const USAGE: string;
7
7
  /**
@@ -7,6 +7,7 @@ export const ENV_KEYS = [
7
7
  "POSTMOCK_API_PORT",
8
8
  "POSTMOCK_CONTROL_PORT",
9
9
  "POSTMOCK_SEED",
10
+ "POSTMOCK_CLOCK",
10
11
  "POSTMOCK_HTTPS_PORT",
11
12
  "POSTMOCK_HTTPS_TLS_KEY",
12
13
  "POSTMOCK_HTTPS_TLS_CERT",
@@ -63,6 +64,7 @@ const envSchema = z.object({
63
64
  POSTMOCK_API_PORT: portSchema.default(8080),
64
65
  POSTMOCK_CONTROL_PORT: portSchema.default(8025),
65
66
  POSTMOCK_SEED: z.string().min(1).default("empty"),
67
+ POSTMOCK_CLOCK: z.enum(["real", "manual"]).default("real"),
66
68
  POSTMOCK_HTTPS_PORT: portSchema.optional(),
67
69
  POSTMOCK_HTTPS_TLS_KEY: z.string().min(1).optional(),
68
70
  POSTMOCK_HTTPS_TLS_CERT: z.string().min(1).optional(),
@@ -75,6 +77,7 @@ export function configFromEnv(env) {
75
77
  apiPort: e.POSTMOCK_API_PORT,
76
78
  controlPort: e.POSTMOCK_CONTROL_PORT,
77
79
  seed: e.POSTMOCK_SEED,
80
+ clock: e.POSTMOCK_CLOCK,
78
81
  };
79
82
  const https = [e.POSTMOCK_HTTPS_PORT, e.POSTMOCK_HTTPS_TLS_KEY, e.POSTMOCK_HTTPS_TLS_CERT];
80
83
  if (https.every((v) => v === undefined))
@@ -3,7 +3,7 @@ import { apiError, ERROR_FAMILIES, isSummaryRow } from "../../errors.js";
3
3
  import { formatTimestamp } from "../../time.js";
4
4
  import { ControlError, controlInput, defineControl } from "../registry.js";
5
5
  import { seedAtomically } from "../seeding.js";
6
- // docs/09 §5: reset, seed, clock, faults, messages.
6
+ // docs/09 §5: reset, seed, clock, latency, faults, messages.
7
7
  defineControl({
8
8
  method: "POST",
9
9
  path: "/control/reset",
@@ -28,7 +28,34 @@ defineControl({
28
28
  handler: async (ctx) => {
29
29
  const { ms } = controlInput(z.object({ ms: z.int().nonnegative() }), ctx.body);
30
30
  await ctx.clock.advance(ms);
31
- return { now: formatTimestamp(ctx.clock.now(), "utc") };
31
+ return clockState(ctx.clock);
32
+ },
33
+ });
34
+ defineControl({
35
+ method: "GET",
36
+ path: "/control/clock",
37
+ handler: (ctx) => clockState(ctx.clock),
38
+ });
39
+ function clockState(clock) {
40
+ return { now: formatTimestamp(clock.now(), "utc"), pending: clock.pending };
41
+ }
42
+ const requestMatch = z.object({ method: z.string(), path: z.string().startsWith("/") });
43
+ defineControl({
44
+ method: "POST",
45
+ path: "/control/latency",
46
+ handler: (ctx) => {
47
+ const { match, times, ms } = controlInput(z.object({
48
+ match: requestMatch,
49
+ times: z.int().positive().default(1),
50
+ ms: z.int().positive(),
51
+ }), ctx.body);
52
+ ctx.store.state.latencies.push({
53
+ method: match.method,
54
+ path: match.path,
55
+ remaining: times,
56
+ ms,
57
+ });
58
+ return { latencies: ctx.store.state.latencies.length };
32
59
  },
33
60
  });
34
61
  const errorReply = z.object({
@@ -38,7 +65,7 @@ const errorReply = z.object({
38
65
  message: z.string().optional(),
39
66
  });
40
67
  const faultSchema = z.object({
41
- match: z.object({ method: z.string(), path: z.string().startsWith("/") }),
68
+ match: requestMatch,
42
69
  times: z.int().positive().default(1),
43
70
  reply: z.unknown(),
44
71
  });
@@ -12,7 +12,7 @@ export function createApiApp(runtime) {
12
12
  app.all("*", async (c) => {
13
13
  const request = c.req.raw;
14
14
  const url = new URL(request.url);
15
- const fault = await applyFault(runtime.store.state, request.method, url.pathname, c.env);
15
+ const fault = await applyFault(runtime, request.method, url.pathname, c.env);
16
16
  if (fault)
17
17
  return fault;
18
18
  const matched = apiRoutes.match(request.method, url.pathname);
@@ -1,8 +1,8 @@
1
1
  import type { HttpBindings } from "@hono/node-server";
2
- import type { State } from "../state/store.ts";
2
+ import type { Runtime } from "../runtime.ts";
3
3
  /**
4
- * Applies the first fault that matches the request and uses one of its `times`
5
- * (docs/09 §5 `POST /control/faults`). `timeout` holds the request open until the client gives up;
6
- * `reset` destroys the socket.
4
+ * Holds the request for the first matching latency rule, then applies the first matching fault
5
+ * (docs/09 §5 `POST /control/latency`, `POST /control/faults`). Each match uses one of the rule's
6
+ * `times`. `timeout` holds the request open until the client gives up; `reset` destroys the socket.
7
7
  */
8
- export declare function applyFault(state: State, method: string, pathname: string, env: HttpBindings): Promise<Response | undefined>;
8
+ export declare function applyFault({ store, clock }: Runtime, method: string, pathname: string, env: HttpBindings): Promise<Response | undefined>;
@@ -2,22 +2,28 @@ import { ApiError } from "../errors.js";
2
2
  import { errorResponse } from "./respond.js";
3
3
  import { RouteTable } from "./routes.js";
4
4
  /**
5
- * Applies the first fault that matches the request and uses one of its `times`
6
- * (docs/09 §5 `POST /control/faults`). `timeout` holds the request open until the client gives up;
7
- * `reset` destroys the socket.
5
+ * Holds the request for the first matching latency rule, then applies the first matching fault
6
+ * (docs/09 §5 `POST /control/latency`, `POST /control/faults`). Each match uses one of the rule's
7
+ * `times`. `timeout` holds the request open until the client gives up; `reset` destroys the socket.
8
8
  */
9
- export async function applyFault(state, method, pathname, env) {
10
- const fault = state.faults.find((f) => {
11
- if (f.remaining <= 0)
9
+ export async function applyFault({ store, clock }, method, pathname, env) {
10
+ const latency = take(store.state.latencies, method, pathname);
11
+ if (latency)
12
+ await clock.hold(latency.ms);
13
+ const fault = take(store.state.faults, method, pathname);
14
+ return fault && reply(fault, env);
15
+ }
16
+ function take(rules, method, pathname) {
17
+ const rule = rules.find((r) => {
18
+ if (r.remaining <= 0)
12
19
  return false;
13
20
  const table = new RouteTable();
14
- table.add({ method: f.method.toUpperCase(), path: f.path });
21
+ table.add({ method: r.method.toUpperCase(), path: r.path });
15
22
  return table.match(method, pathname) !== undefined;
16
23
  });
17
- if (!fault)
18
- return undefined;
19
- fault.remaining -= 1;
20
- return reply(fault, env);
24
+ if (rule)
25
+ rule.remaining -= 1;
26
+ return rule;
21
27
  }
22
28
  async function reply(fault, env) {
23
29
  const { reply } = fault;
@@ -1,5 +1,6 @@
1
1
  import { type Plugin } from "./plugins.ts";
2
2
  import { type Runtime } from "./runtime.ts";
3
+ import { type ClockMode } from "./state/clock.ts";
3
4
  export interface PostmockConfig {
4
5
  host: string;
5
6
  /** Plain-http REST port; 0 picks a free port. */
@@ -12,6 +13,8 @@ export interface PostmockConfig {
12
13
  cert: string;
13
14
  };
14
15
  seed: string;
16
+ /** `manual`: time moves only on `POST /control/clock/advance`. */
17
+ clock: ClockMode;
15
18
  /** Defaults to every plugin in `src/plugins/`. */
16
19
  plugins?: readonly Plugin[];
17
20
  }
@@ -5,6 +5,7 @@ import { applySeed } from "./control/seed.js";
5
5
  import { createApiApp } from "./http/app.js";
6
6
  import { PLUGINS } from "./plugins.js";
7
7
  import { createRuntime } from "./runtime.js";
8
+ import { Clock } from "./state/clock.js";
8
9
  function listen(name, fetch, host, port, tls) {
9
10
  return new Promise((resolve) => {
10
11
  const onListen = (info) => resolve({
@@ -19,7 +20,7 @@ function listen(name, fetch, host, port, tls) {
19
20
  }
20
21
  /** Seeds the state, then starts the REST, control and plugin listeners. */
21
22
  export async function startPostmock(config) {
22
- const runtime = createRuntime(config.plugins ?? PLUGINS);
23
+ const runtime = createRuntime(config.plugins ?? PLUGINS, new Clock(Date.now, config.clock));
23
24
  await applySeed(runtime, config.seed);
24
25
  const api = createApiApp(runtime);
25
26
  const started = [await listen("api", api.fetch, config.host, config.apiPort)];
@@ -41,9 +42,10 @@ export async function startPostmock(config) {
41
42
  runtime,
42
43
  listeners: Object.fromEntries(started.map((l) => [l.name, l.url])),
43
44
  close: async () => {
44
- await Promise.all(started.map((l) => l.close()));
45
+ // Reset first: a listener closes only after its held requests answer.
45
46
  await runtime.clock.idle();
46
47
  runtime.clock.reset();
48
+ await Promise.all(started.map((l) => l.close()));
47
49
  },
48
50
  };
49
51
  }
@@ -1,19 +1,29 @@
1
+ /** `real`: time follows real time plus advances. `manual`: time moves only on `advance()`. */
2
+ export type ClockMode = "real" | "manual";
1
3
  type Run = () => void | Promise<void>;
2
4
  /**
3
5
  * Real time plus an offset that `advance()` grows (docs/09 §5 `clock/advance`).
4
6
  * A scheduled task runs once: when real time reaches it, or when `advance()` passes it.
7
+ * A `manual` clock starts at real time, stands still, and only `advance()` moves it and runs tasks.
5
8
  * Advances and real-timer tasks run one at a time, in the order they start.
6
9
  */
7
10
  export declare class Clock {
8
11
  private offsetMs;
12
+ /** The instant a manual clock stands at; `undefined` on a real clock. */
13
+ private manualNow;
9
14
  private seq;
10
15
  private advancing;
11
16
  private queue;
12
17
  private readonly tasks;
18
+ private readonly holds;
13
19
  private readonly realNow;
14
- constructor(realNow?: () => number);
20
+ constructor(realNow?: () => number, mode?: ClockMode);
15
21
  now(): Date;
22
+ /** The number of tasks not yet run. */
23
+ get pending(): number;
16
24
  schedule(delayMs: number, run: Run): void;
25
+ /** Resolves after `delayMs` on the clock, or at `reset()`, so a held request always answers. */
26
+ hold(delayMs: number): Promise<void>;
17
27
  /**
18
28
  * Moves time forward by `ms`, after any advance already running. Runs each task due by the end in
19
29
  * due order, with `now()` set to its due time, and awaits it. A task scheduled during the advance
@@ -22,9 +32,12 @@ export declare class Clock {
22
32
  advance(ms: number): Promise<void>;
23
33
  /** Resolves when every advance and real-timer task started so far has finished. */
24
34
  idle(): Promise<void>;
25
- /** Drops pending tasks and the offset. Throws during an advance, which would undo it. */
35
+ /**
36
+ * Drops pending tasks and the offset, and releases every hold; a manual clock stands at real time
37
+ * again. Throws during an advance, which would undo it.
38
+ */
26
39
  reset(): void;
27
- /** Captures offset and pending tasks; the returned function puts them back. */
40
+ /** Captures time and pending tasks; the returned function puts them back. */
28
41
  checkpoint(): () => void;
29
42
  private add;
30
43
  private enqueue;
@@ -3,20 +3,30 @@ const MAX_TIMER_MS = 2 ** 31 - 1;
3
3
  /**
4
4
  * Real time plus an offset that `advance()` grows (docs/09 §5 `clock/advance`).
5
5
  * A scheduled task runs once: when real time reaches it, or when `advance()` passes it.
6
+ * A `manual` clock starts at real time, stands still, and only `advance()` moves it and runs tasks.
6
7
  * Advances and real-timer tasks run one at a time, in the order they start.
7
8
  */
8
9
  export class Clock {
9
10
  offsetMs = 0;
11
+ /** The instant a manual clock stands at; `undefined` on a real clock. */
12
+ manualNow;
10
13
  seq = 0;
11
14
  advancing = false;
12
15
  queue = Promise.resolve();
13
16
  tasks = new Set();
17
+ holds = new Set();
14
18
  realNow;
15
- constructor(realNow = Date.now) {
19
+ constructor(realNow = Date.now, mode = "real") {
16
20
  this.realNow = realNow;
21
+ if (mode === "manual")
22
+ this.manualNow = realNow();
17
23
  }
18
24
  now() {
19
- return new Date(this.realNow() + this.offsetMs);
25
+ return new Date(this.manualNow ?? this.realNow() + this.offsetMs);
26
+ }
27
+ /** The number of tasks not yet run. */
28
+ get pending() {
29
+ return this.tasks.size;
20
30
  }
21
31
  schedule(delayMs, run) {
22
32
  if (!Number.isInteger(delayMs) || delayMs < 0) {
@@ -24,6 +34,16 @@ export class Clock {
24
34
  }
25
35
  this.add({ due: this.now().getTime() + delayMs, seq: this.seq++, run, timer: undefined });
26
36
  }
37
+ /** Resolves after `delayMs` on the clock, or at `reset()`, so a held request always answers. */
38
+ hold(delayMs) {
39
+ return new Promise((resolve) => {
40
+ this.holds.add(resolve);
41
+ this.schedule(delayMs, () => {
42
+ this.holds.delete(resolve);
43
+ resolve();
44
+ });
45
+ });
46
+ }
27
47
  /**
28
48
  * Moves time forward by `ms`, after any advance already running. Runs each task due by the end in
29
49
  * due order, with `now()` set to its due time, and awaits it. A task scheduled during the advance
@@ -57,27 +77,39 @@ export class Clock {
57
77
  idle() {
58
78
  return this.queue;
59
79
  }
60
- /** Drops pending tasks and the offset. Throws during an advance, which would undo it. */
80
+ /**
81
+ * Drops pending tasks and the offset, and releases every hold; a manual clock stands at real time
82
+ * again. Throws during an advance, which would undo it.
83
+ */
61
84
  reset() {
62
85
  if (this.advancing)
63
86
  throw new Error("clock.reset during an advance");
64
87
  for (const task of this.tasks)
65
88
  clearTimeout(task.timer);
66
89
  this.tasks.clear();
90
+ for (const release of this.holds)
91
+ release();
92
+ this.holds.clear();
67
93
  this.offsetMs = 0;
94
+ if (this.manualNow !== undefined)
95
+ this.manualNow = this.realNow();
68
96
  }
69
- /** Captures offset and pending tasks; the returned function puts them back. */
97
+ /** Captures time and pending tasks; the returned function puts them back. */
70
98
  checkpoint() {
71
- const offsetMs = this.offsetMs;
99
+ const { offsetMs, manualNow } = this;
72
100
  const tasks = [...this.tasks].map(({ due, seq, run }) => ({ due, seq, run }));
73
101
  return () => {
74
102
  this.reset();
75
103
  this.offsetMs = offsetMs;
104
+ this.manualNow = manualNow;
76
105
  for (const task of tasks)
77
106
  this.add({ ...task, timer: undefined });
78
107
  };
79
108
  }
80
109
  add(task) {
110
+ this.tasks.add(task);
111
+ if (this.manualNow !== undefined)
112
+ return;
81
113
  const delay = task.due - this.now().getTime();
82
114
  if (delay <= MAX_TIMER_MS) {
83
115
  // A task that throws on a real timer is a postmock bug: rethrow so the process crashes.
@@ -87,7 +119,6 @@ export class Clock {
87
119
  }));
88
120
  }, Math.max(0, delay)).unref();
89
121
  }
90
- this.tasks.add(task);
91
122
  }
92
123
  enqueue(work) {
93
124
  const result = this.queue.then(work);
@@ -96,7 +127,11 @@ export class Clock {
96
127
  }
97
128
  /** Time never moves back. */
98
129
  moveTo(instant) {
99
- this.offsetMs = Math.max(this.now().getTime(), instant) - this.realNow();
130
+ const target = Math.max(this.now().getTime(), instant);
131
+ if (this.manualNow === undefined)
132
+ this.offsetMs = target - this.realNow();
133
+ else
134
+ this.manualNow = target;
100
135
  }
101
136
  async take(task) {
102
137
  if (!this.tasks.delete(task))
@@ -1,4 +1,4 @@
1
- import type { Account, Bounce, BulkRequest, ClickEvent, DataRemoval, Domain, Fault, InboundMessage, InboundRule, MessageStream, OpenEvent, OutboundMessage, SenderSignature, Server, SmtpFault, SmtpToken, StatsFact, Suppression, Template, Webhook, WebhookAttempt } from "./types.ts";
1
+ import type { Account, Bounce, BulkRequest, ClickEvent, DataRemoval, Domain, Fault, InboundMessage, InboundRule, Latency, MessageStream, OpenEvent, OutboundMessage, SenderSignature, Server, SmtpFault, SmtpToken, StatsFact, Suppression, Template, Webhook, WebhookAttempt } from "./types.ts";
2
2
  export type IdKind = "server" | "bounce" | "template" | "webhook" | "webhookAttempt" | "inboundRule" | "domain" | "sender" | "dataRemoval";
3
3
  export interface State {
4
4
  account: Account;
@@ -32,6 +32,7 @@ export interface State {
32
32
  /** Key: access key. */
33
33
  smtpTokens: Map<string, SmtpToken>;
34
34
  faults: Fault[];
35
+ latencies: Latency[];
35
36
  smtpFaults: SmtpFault[];
36
37
  usedIds: Record<IdKind, Set<number>>;
37
38
  /** Highest used ID per kind. */
@@ -29,6 +29,7 @@ function emptyState() {
29
29
  dataRemovals: new Map(),
30
30
  smtpTokens: new Map(),
31
31
  faults: [],
32
+ latencies: [],
32
33
  smtpFaults: [],
33
34
  usedIds: {
34
35
  server: new Set(),
@@ -521,10 +521,17 @@ export type FaultReply = {
521
521
  Message: string;
522
522
  };
523
523
  } | "timeout" | "reset";
524
- export interface Fault {
524
+ /** A control-API rule for REST requests: `path` is a route pattern, matched like an API route. */
525
+ export interface RequestRule {
525
526
  method: string;
526
- /** A route pattern, matched like an API route (`/email`, `/templates/:id`). */
527
+ /** `/email`, `/templates/:id` */
527
528
  path: string;
528
529
  remaining: number;
530
+ }
531
+ export interface Fault extends RequestRule {
529
532
  reply: FaultReply;
530
533
  }
534
+ /** `POST /control/latency`: the request waits `ms` on the clock, then goes on as usual. */
535
+ export interface Latency extends RequestRule {
536
+ ms: number;
537
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benclmnt/postmock",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "A mock Postmark server for testing code that sends email through Postmark",
5
5
  "license": "MIT",
6
6
  "repository": {