@vereda/http 1.0.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/adapters/zod.d.ts +14 -0
  4. package/dist/adapters/zod.d.ts.map +1 -0
  5. package/dist/adapters/zod.js +14 -0
  6. package/dist/adapters/zod.js.map +1 -0
  7. package/dist/core/backoff.d.ts +9 -0
  8. package/dist/core/backoff.d.ts.map +1 -0
  9. package/dist/core/backoff.js +23 -0
  10. package/dist/core/backoff.js.map +1 -0
  11. package/dist/core/client.d.ts +98 -0
  12. package/dist/core/client.d.ts.map +1 -0
  13. package/dist/core/client.js +781 -0
  14. package/dist/core/client.js.map +1 -0
  15. package/dist/core/errors.d.ts +87 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +140 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/index.d.ts +15 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +10 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/listeners.d.ts +14 -0
  24. package/dist/core/listeners.d.ts.map +1 -0
  25. package/dist/core/listeners.js +27 -0
  26. package/dist/core/listeners.js.map +1 -0
  27. package/dist/core/metrics.d.ts +33 -0
  28. package/dist/core/metrics.d.ts.map +1 -0
  29. package/dist/core/metrics.js +24 -0
  30. package/dist/core/metrics.js.map +1 -0
  31. package/dist/core/nanoid.d.ts +2 -0
  32. package/dist/core/nanoid.d.ts.map +1 -0
  33. package/dist/core/nanoid.js +11 -0
  34. package/dist/core/nanoid.js.map +1 -0
  35. package/dist/core/redact.d.ts +12 -0
  36. package/dist/core/redact.d.ts.map +1 -0
  37. package/dist/core/redact.js +42 -0
  38. package/dist/core/redact.js.map +1 -0
  39. package/dist/core/types.d.ts +261 -0
  40. package/dist/core/types.d.ts.map +1 -0
  41. package/dist/core/types.js +41 -0
  42. package/dist/core/types.js.map +1 -0
  43. package/dist/core/validate.d.ts +19 -0
  44. package/dist/core/validate.d.ts.map +1 -0
  45. package/dist/core/validate.js +135 -0
  46. package/dist/core/validate.js.map +1 -0
  47. package/dist/middleware/index.d.ts +26 -0
  48. package/dist/middleware/index.d.ts.map +1 -0
  49. package/dist/middleware/index.js +55 -0
  50. package/dist/middleware/index.js.map +1 -0
  51. package/dist/queue/bulkhead.d.ts +63 -0
  52. package/dist/queue/bulkhead.d.ts.map +1 -0
  53. package/dist/queue/bulkhead.js +192 -0
  54. package/dist/queue/bulkhead.js.map +1 -0
  55. package/dist/queue/circuit-breaker.d.ts +81 -0
  56. package/dist/queue/circuit-breaker.d.ts.map +1 -0
  57. package/dist/queue/circuit-breaker.js +283 -0
  58. package/dist/queue/circuit-breaker.js.map +1 -0
  59. package/dist/queue/executor.d.ts +67 -0
  60. package/dist/queue/executor.d.ts.map +1 -0
  61. package/dist/queue/executor.js +273 -0
  62. package/dist/queue/executor.js.map +1 -0
  63. package/dist/queue/policy.d.ts +26 -0
  64. package/dist/queue/policy.d.ts.map +1 -0
  65. package/dist/queue/policy.js +37 -0
  66. package/dist/queue/policy.js.map +1 -0
  67. package/dist/queue/retry.d.ts +58 -0
  68. package/dist/queue/retry.d.ts.map +1 -0
  69. package/dist/queue/retry.js +259 -0
  70. package/dist/queue/retry.js.map +1 -0
  71. package/dist/queue/semaphore.d.ts +32 -0
  72. package/dist/queue/semaphore.d.ts.map +1 -0
  73. package/dist/queue/semaphore.js +83 -0
  74. package/dist/queue/semaphore.js.map +1 -0
  75. package/dist/ticket/ticket.d.ts +77 -0
  76. package/dist/ticket/ticket.d.ts.map +1 -0
  77. package/dist/ticket/ticket.js +186 -0
  78. package/dist/ticket/ticket.js.map +1 -0
  79. package/package.json +85 -0
  80. package/src/adapters/zod.ts +16 -0
  81. package/src/core/backoff.ts +26 -0
  82. package/src/core/client.ts +1048 -0
  83. package/src/core/errors.ts +194 -0
  84. package/src/core/index.ts +56 -0
  85. package/src/core/listeners.ts +28 -0
  86. package/src/core/metrics.ts +42 -0
  87. package/src/core/nanoid.ts +11 -0
  88. package/src/core/redact.ts +46 -0
  89. package/src/core/types.ts +306 -0
  90. package/src/core/validate.ts +163 -0
  91. package/src/middleware/index.ts +63 -0
  92. package/src/queue/bulkhead.ts +243 -0
  93. package/src/queue/circuit-breaker.ts +373 -0
  94. package/src/queue/executor.ts +355 -0
  95. package/src/queue/policy.ts +49 -0
  96. package/src/queue/retry.ts +380 -0
  97. package/src/queue/semaphore.ts +91 -0
  98. package/src/ticket/ticket.ts +246 -0
@@ -0,0 +1,246 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { AppError } from "../core/errors.ts";
3
+ import { CancelledError } from "../core/errors.ts";
4
+ import { emitIsolated } from "../core/listeners.ts";
5
+ import type { Result } from "../core/types.ts";
6
+
7
+ export type TicketStatus =
8
+ | { state: "pending" }
9
+ | { state: "queued" }
10
+ | { state: "retrying"; attempt: number }
11
+ | { state: "done"; result: Result<unknown> }
12
+ | { state: "cancelled" };
13
+
14
+ export type TicketUpdate =
15
+ | { type: "queued" }
16
+ | { type: "retrying"; attempt: number; delayMs: number }
17
+ | { type: "done"; result: Result<unknown> }
18
+ | { type: "cancelled" };
19
+
20
+ // Allowed transitions between ticket states. `done` and `cancelled` are both
21
+ // terminal: `cancel()` resolves the ticket directly with a CancelledError
22
+ // result, so a cancelled ticket is never transitioned to `done` by the retry
23
+ // loop (its `_markDone` becomes a no-op). The `retrying -> retrying` self-loop
24
+ // permits re-entering `retrying` with a new attempt number (a fresh status
25
+ // object is assigned, not mutated in place). `pending -> done` is the
26
+ // first-attempt success path: client.ts calls `_markDone` directly from
27
+ // `pending` when the initial request succeeds, so it is an intentional
28
+ // shortcut, not a gap in the lifecycle.
29
+ const ALLOWED_TRANSITIONS: Record<TicketStatus["state"], TicketStatus["state"][]> = {
30
+ pending: ["queued", "done", "cancelled"],
31
+ queued: ["retrying", "done", "cancelled"],
32
+ retrying: ["retrying", "done", "cancelled"],
33
+ cancelled: [],
34
+ done: [],
35
+ };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // TicketController — the only way to mutate a ticket's lifecycle
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface TicketController<T> {
42
+ markQueued(): void;
43
+ markRetrying(attempt: number, delayMs: number): void;
44
+ markDone(result: Result<T>): void;
45
+ /** @internal — abort the signal without resolving the ticket. Used by the
46
+ * deadline timer so the retry loop can resolve with DeadlineExceededError
47
+ * instead of CancelledError. */
48
+ abortSignal(): void;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Ticket
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export class Ticket<T> {
56
+ public readonly id: string;
57
+
58
+ private readonly emitter = new EventEmitter();
59
+ private _status: TicketStatus = { state: "pending" };
60
+ private _cancelled = false;
61
+ private _abortController = new AbortController();
62
+ private _resolve!: (result: Result<T>) => void;
63
+ private _promise: Promise<Result<T>>;
64
+
65
+ /** @internal — use `createTicket()` to obtain a ticket and its controller. */
66
+ constructor(id: string) {
67
+ this.id = id;
68
+ this._promise = new Promise<Result<T>>((resolve) => {
69
+ this._resolve = resolve;
70
+ });
71
+ // Prevent Node from throwing on unhandled "error" events
72
+ this.emitter.on("error", () => {});
73
+ }
74
+
75
+ get status(): TicketStatus {
76
+ return this._status;
77
+ }
78
+
79
+ /** Validate and apply a state transition. Returns false (and leaves the
80
+ * current state untouched) when `next` is not reachable from the current
81
+ * state, so illegal transitions are ignored rather than corrupting state. */
82
+ private applyTransition(next: TicketStatus): boolean {
83
+ if (!ALLOWED_TRANSITIONS[this._status.state].includes(next.state)) {
84
+ return false;
85
+ }
86
+ this._status = next;
87
+ return true;
88
+ }
89
+
90
+ get signal(): AbortSignal {
91
+ return this._abortController.signal;
92
+ }
93
+
94
+ // -- Event subscriptions --------------------------------------------------
95
+
96
+ on(event: "done", listener: (result: Result<T>) => void): this;
97
+ on(event: "error", listener: (error: AppError) => void): this;
98
+ on(event: "update", listener: (update: TicketUpdate) => void): this;
99
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation signature must accept every declared overload; callers only see the typed overloads.
100
+ on(event: string, listener: (...args: any[]) => void): this {
101
+ // biome-ignore lint/suspicious/noExplicitAny: EventEmitter's own listener signature.
102
+ this.emitter.on(event, listener as (...args: any[]) => void);
103
+ return this;
104
+ }
105
+
106
+ off(event: "done", listener: (result: Result<T>) => void): this;
107
+ off(event: "error", listener: (error: AppError) => void): this;
108
+ off(event: "update", listener: (update: TicketUpdate) => void): this;
109
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation signature must accept every declared overload; callers only see the typed overloads.
110
+ off(event: string, listener: (...args: any[]) => void): this {
111
+ // biome-ignore lint/suspicious/noExplicitAny: EventEmitter's own listener signature.
112
+ this.emitter.off(event, listener as (...args: any[]) => void);
113
+ return this;
114
+ }
115
+
116
+ // -- Public API ------------------------------------------------------------
117
+
118
+ cancel(): void {
119
+ if (this._cancelled) return;
120
+ // `cancelled` is terminal — if the ticket already resolved, this is a no-op.
121
+ if (!this.applyTransition({ state: "cancelled" })) return;
122
+ this._cancelled = true;
123
+ this._abortController.abort();
124
+ const result: Result<T> = { success: false, error: new CancelledError() };
125
+ emitIsolated(this.emitter, "update", { type: "cancelled" } as TicketUpdate);
126
+ emitIsolated(this.emitter, "done", result);
127
+ emitIsolated(this.emitter, "error", result.error);
128
+ this._resolve(result);
129
+ }
130
+
131
+ toPromise(): Promise<Result<T>> {
132
+ return this._promise;
133
+ }
134
+
135
+ async *subscribe(): AsyncGenerator<TicketUpdate> {
136
+ // Already terminal — yield synthetic update and return
137
+ const current = this._status;
138
+ if (current.state === "done") {
139
+ yield { type: "done", result: current.result };
140
+ return;
141
+ }
142
+ if (current.state === "cancelled") {
143
+ yield { type: "cancelled" };
144
+ return;
145
+ }
146
+
147
+ const updates: TicketUpdate[] = [];
148
+ let notify: (() => void) | null = null;
149
+ let isDone = false;
150
+
151
+ const onUpdate = (update: TicketUpdate) => {
152
+ updates.push(update);
153
+ notify?.();
154
+ notify = null;
155
+ if (update.type === "done" || update.type === "cancelled") {
156
+ isDone = true;
157
+ }
158
+ };
159
+
160
+ this.emitter.on("update", onUpdate);
161
+
162
+ try {
163
+ while (!isDone || updates.length > 0) {
164
+ if (updates.length > 0) {
165
+ yield updates.shift()!;
166
+ } else {
167
+ await new Promise<void>((r) => {
168
+ notify = r;
169
+ });
170
+ }
171
+ }
172
+ } finally {
173
+ this.emitter.off("update", onUpdate);
174
+ }
175
+ }
176
+
177
+ get isCancelled(): boolean {
178
+ return this._cancelled;
179
+ }
180
+
181
+ /** True once this ticket has reached a terminal state by any path — a
182
+ * normal `done` transition or `cancel()` (which resolves the ticket
183
+ * directly, bypassing `done`). A terminal-event emitter must check this
184
+ * before firing, or a result racing a concurrent cancel() can emit a
185
+ * second, contradictory terminal event for the same ticket. */
186
+ get isSettled(): boolean {
187
+ return this._status.state === "done" || this._cancelled;
188
+ }
189
+
190
+ // -- Internal mutators (private, accessed via TicketController) ------------
191
+
192
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: reached via bracket notation from createTicket(); Biome cannot see that access.
193
+ private markQueued(): void {
194
+ if (!this.applyTransition({ state: "queued" })) return;
195
+ emitIsolated(this.emitter, "update", { type: "queued" } as TicketUpdate);
196
+ }
197
+
198
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: reached via bracket notation from createTicket(); Biome cannot see that access.
199
+ private markRetrying(attempt: number, delayMs: number): void {
200
+ if (!this.applyTransition({ state: "retrying", attempt })) return;
201
+ emitIsolated(this.emitter, "update", {
202
+ type: "retrying",
203
+ attempt,
204
+ delayMs,
205
+ } as TicketUpdate);
206
+ }
207
+
208
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: reached via bracket notation from createTicket(); Biome cannot see that access.
209
+ private markDone(result: Result<T>): void {
210
+ if (!this.applyTransition({ state: "done", result })) return;
211
+ emitIsolated(this.emitter, "update", { type: "done", result } as TicketUpdate);
212
+ emitIsolated(this.emitter, "done", result);
213
+ if (result.success === false) {
214
+ emitIsolated(this.emitter, "error", result.error);
215
+ }
216
+ this._resolve(result);
217
+ }
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Factory — the only public way to get a ticket and its controller
222
+ // ---------------------------------------------------------------------------
223
+
224
+ export function createTicket<T>(id: string): {
225
+ ticket: Ticket<T>;
226
+ controller: TicketController<T>;
227
+ } {
228
+ const ticket = new Ticket<T>(id);
229
+
230
+ // Bind the private methods to the ticket instance and expose them
231
+ // through the controller interface.
232
+ const controller: TicketController<T> = {
233
+ // Bracket notation accesses private methods — the type boundary prevents
234
+ // external callers from reaching these, while the controller provides
235
+ // a clean compile-time API for internal use.
236
+ // biome-ignore-start lint/complexity/useLiteralKeys: these members are private; dot
237
+ // access is a compile error, bracket notation is the deliberate escape hatch.
238
+ markQueued: () => ticket["markQueued"](),
239
+ markRetrying: (attempt, delayMs) => ticket["markRetrying"](attempt, delayMs),
240
+ markDone: (result) => ticket["markDone"](result),
241
+ abortSignal: () => ticket["_abortController"].abort(),
242
+ // biome-ignore-end lint/complexity/useLiteralKeys: restore the rule.
243
+ };
244
+
245
+ return { ticket, controller };
246
+ }