@muspellheim/shared 0.9.3 → 0.11.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.
@@ -0,0 +1,60 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ /**
4
+ * A clock provides access to the current timestamp.
5
+ */
6
+ export class Clock {
7
+ /**
8
+ * Create a clock using system the clock.
9
+ *
10
+ * @return A clock that uses the system clock.
11
+ */
12
+ static system(): Clock {
13
+ return new Clock();
14
+ }
15
+
16
+ /**
17
+ * Create a clock using a fixed date.
18
+ *
19
+ * @param date The fixed date of the clock.
20
+ * @return A clock that always returns a fixed date.
21
+ */
22
+ static fixed(date: Date | string | number): Clock {
23
+ return new Clock(new Date(date));
24
+ }
25
+
26
+ /**
27
+ * Create a clock that returns a fixed offset from the given clock.
28
+ *
29
+ * @param clock The clock to offset from.
30
+ * @param offsetMillis The offset in milliseconds.
31
+ * @return A clock that returns a fixed offset from the given clock.
32
+ */
33
+ static offset(clock: Clock, offsetMillis: number): Clock {
34
+ return new Clock(new Date(clock.millis() + offsetMillis));
35
+ }
36
+
37
+ readonly #date?: Date;
38
+
39
+ private constructor(date?: Date) {
40
+ this.#date = date;
41
+ }
42
+
43
+ /**
44
+ * Return the current timestamp of the clock.
45
+ *
46
+ * @return The current timestamp.
47
+ */
48
+ date(): Date {
49
+ return this.#date ? new Date(this.#date) : new Date();
50
+ }
51
+
52
+ /**
53
+ * Return the current timestamp of the clock in milliseconds.
54
+ *
55
+ * @return The current timestamp in milliseconds.
56
+ */
57
+ millis(): number {
58
+ return this.date().getTime();
59
+ }
60
+ }
@@ -0,0 +1,90 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+ // Copyright 2023 Titanium I.T. LLC. MIT License.
3
+
4
+ /**
5
+ * Handle returning pre-configured responses.
6
+ *
7
+ * This is one of the nullability patterns from James Shore's article on
8
+ * [testing without mocks](https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#configurable-responses).
9
+ *
10
+ * Example usage for stubbing `fetch` function:
11
+ *
12
+ * ```javascript
13
+ * function createFetchStub(responses) {
14
+ * const configurableResponses = ConfigurableResponses.create(responses);
15
+ * return async function () {
16
+ * const response = configurableResponses.next();
17
+ * return {
18
+ * status: response.status,
19
+ * json: async () => response.body,
20
+ * };
21
+ * };
22
+ * }
23
+ * ```
24
+ */
25
+ export class ConfigurableResponses<T = unknown> {
26
+ /**
27
+ * Create a list of responses (by providing an array), or a single repeating
28
+ * response (by providing any other type). 'Name' is optional and used in
29
+ * error messages.
30
+ *
31
+ * @param responses A single response or an array of responses.
32
+ * @param name An optional name for the responses.
33
+ */
34
+ static create<T>(responses?: T | T[], name?: string) {
35
+ return new ConfigurableResponses<T>(responses, name);
36
+ }
37
+
38
+ /**
39
+ * Convert all properties in an object into ConfigurableResponse instances.
40
+ * For example, { a: 1 } becomes { a: ConfigurableResponses.create(1) }.
41
+ * 'Name' is optional and used in error messages.
42
+ *
43
+ * @param responseObject An object with single response or an array of responses.
44
+ * @param name An optional name for the responses.
45
+ */
46
+ static mapObject<T extends Record<string, unknown>>(
47
+ responseObject: T,
48
+ name?: string,
49
+ ) {
50
+ const entries = Object.entries(responseObject);
51
+ const translatedEntries = entries.map(([key, value]) => {
52
+ const translatedName = name === undefined ? undefined : `${name}: ${key}`;
53
+ return [key, ConfigurableResponses.create(value, translatedName)];
54
+ });
55
+ return Object.fromEntries(translatedEntries);
56
+ }
57
+
58
+ readonly #description;
59
+ readonly #responses;
60
+
61
+ /**
62
+ * Create a list of responses (by providing an array), or a single repeating
63
+ * response (by providing any other type). 'Name' is optional and used in
64
+ * error messages.
65
+ *
66
+ * @param responses A single response or an array of responses.
67
+ * @param name An optional name for the responses.
68
+ */
69
+ constructor(responses?: T | T[], name?: string) {
70
+ this.#description = name == null ? "" : ` in ${name}`;
71
+ this.#responses = Array.isArray(responses) ? [...responses] : responses;
72
+ }
73
+
74
+ /**
75
+ * Get the next configured response. Throws an error when configured with a list
76
+ * of responses and no more responses remain.
77
+ *
78
+ * @return The next response.
79
+ */
80
+ next(): T {
81
+ const response = Array.isArray(this.#responses)
82
+ ? this.#responses.shift()
83
+ : this.#responses;
84
+ if (response === undefined) {
85
+ throw new Error(`No more responses configured${this.#description}.`);
86
+ }
87
+
88
+ return response;
89
+ }
90
+ }
@@ -0,0 +1,17 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ /**
4
+ * A simple logging facade.
5
+ *
6
+ * This is a subset of the `console` interface.
7
+ *
8
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Console_API
9
+ */
10
+ export interface Log {
11
+ log(...data: unknown[]): void;
12
+ error(...data: unknown[]): void;
13
+ warn(...data: unknown[]): void;
14
+ info(...data: unknown[]): void;
15
+ debug(...data: unknown[]): void;
16
+ trace(...data: unknown[]): void;
17
+ }
@@ -0,0 +1,6 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ export * from "./clock";
4
+ export * from "./configurable_responses";
5
+ export * from "./log";
6
+ export * from "./output_tracker";
@@ -0,0 +1,89 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+ // Copyright 2020-2022 Titanium I.T. LLC. MIT License.
3
+
4
+ /**
5
+ * Track output events.
6
+ *
7
+ * This is one of the nullability patterns from James Shore's article on
8
+ * [testing without mocks](https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#output-tracking).
9
+ *
10
+ * Example implementation of an event store:
11
+ *
12
+ * ```javascript
13
+ * async record(event) {
14
+ * // ...
15
+ * this.dispatchEvent(new CustomEvent("eventRecorded", { detail: event }));
16
+ * }
17
+ *
18
+ * trackEventsRecorded() {
19
+ * return new OutputTracker(this, "eventRecorded");
20
+ * }
21
+ * ```
22
+ *
23
+ * Example usage:
24
+ *
25
+ * ```javascript
26
+ * const eventsRecorded = eventStore.trackEventsRecorded();
27
+ * // ...
28
+ * const data = eventsRecorded.data(); // [event1, event2, ...]
29
+ * ```
30
+ */
31
+ export class OutputTracker<T = unknown> {
32
+ /**
33
+ * Create a tracker for a specific event of an event target.
34
+ *
35
+ * @param eventTarget The target to track.
36
+ * @param event The event name to track.
37
+ */
38
+ static create<T>(eventTarget: EventTarget, event: string) {
39
+ return new OutputTracker<T>(eventTarget, event);
40
+ }
41
+
42
+ readonly #eventTarget;
43
+ readonly #event;
44
+ readonly #data: T[];
45
+ readonly #tracker;
46
+
47
+ /**
48
+ * Create a tracker for a specific event of an event target.
49
+ *
50
+ * @param eventTarget The target to track.
51
+ * @param event The event name to track.
52
+ */
53
+ constructor(eventTarget: EventTarget, event: string) {
54
+ this.#eventTarget = eventTarget;
55
+ this.#event = event;
56
+ this.#data = [];
57
+ this.#tracker = (event: Event) =>
58
+ this.#data.push((event as CustomEvent<T>).detail);
59
+
60
+ this.#eventTarget.addEventListener(this.#event, this.#tracker);
61
+ }
62
+
63
+ /**
64
+ * Return the tracked data.
65
+ *
66
+ * @return The tracked data.
67
+ */
68
+ get data(): T[] {
69
+ return this.#data;
70
+ }
71
+
72
+ /**
73
+ * Clear the tracked data and return the cleared data.
74
+ *
75
+ * @return The cleared data.
76
+ */
77
+ clear(): T[] {
78
+ const result = [...this.#data];
79
+ this.#data.length = 0;
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Stop tracking.
85
+ */
86
+ stop() {
87
+ this.#eventTarget.removeEventListener(this.#event, this.#tracker);
88
+ }
89
+ }
@@ -0,0 +1,53 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ /**
4
+ * Provides CQNS features.
5
+ *
6
+ * The Command Query Notification Separation principle is a software design
7
+ * principle that separates the concerns of commands, queries, and
8
+ * notifications.
9
+ *
10
+ * Message hierarchy:
11
+ *
12
+ * - Message
13
+ * - Incoming / outgoing
14
+ * - Request (outgoing) -> response (incoming)
15
+ * - Command -> command status
16
+ * - Query -> query result
17
+ * - Notification
18
+ * - Incoming: notification -> commands
19
+ * - Outgoing
20
+ * - Event (internal)
21
+ *
22
+ * @see https://ralfw.de/command-query-notification-separation-cqns/
23
+ * @module
24
+ */
25
+
26
+ /**
27
+ * The status returned by a command handler.
28
+ */
29
+ export type CommandStatus = Success | Failure;
30
+
31
+ /**
32
+ * A successful status.
33
+ */
34
+ export class Success {
35
+ readonly isSuccess = true;
36
+ }
37
+
38
+ /**
39
+ * A failed status.
40
+ */
41
+ export class Failure {
42
+ readonly isSuccess = false;
43
+ errorMessage: string;
44
+
45
+ /**
46
+ * Creates a failed status.
47
+ *
48
+ * @param errorMessage
49
+ */
50
+ constructor(errorMessage: string) {
51
+ this.errorMessage = errorMessage;
52
+ }
53
+ }
@@ -0,0 +1,3 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ export * from "./messages";
@@ -0,0 +1,67 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ import { OutputTracker } from "../common/output_tracker";
4
+
5
+ const MESSAGE_EVENT = "message";
6
+
7
+ /**
8
+ * A stub for the console interface.
9
+ *
10
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Console_API
11
+ */
12
+ export class ConsoleStub extends EventTarget {
13
+ log(...data: unknown[]) {
14
+ this.dispatchEvent(
15
+ new CustomEvent(MESSAGE_EVENT, {
16
+ detail: { level: "log", message: data },
17
+ }),
18
+ );
19
+ }
20
+
21
+ error(...data: unknown[]) {
22
+ this.dispatchEvent(
23
+ new CustomEvent(MESSAGE_EVENT, {
24
+ detail: { level: "error", message: data },
25
+ }),
26
+ );
27
+ }
28
+
29
+ warn(...data: unknown[]) {
30
+ this.dispatchEvent(
31
+ new CustomEvent(MESSAGE_EVENT, {
32
+ detail: { level: "warn", message: data },
33
+ }),
34
+ );
35
+ }
36
+
37
+ info(...data: unknown[]) {
38
+ this.dispatchEvent(
39
+ new CustomEvent(MESSAGE_EVENT, {
40
+ detail: { level: "info", message: data },
41
+ }),
42
+ );
43
+ }
44
+
45
+ debug(...data: unknown[]) {
46
+ this.dispatchEvent(
47
+ new CustomEvent(MESSAGE_EVENT, {
48
+ detail: { level: "debug", message: data },
49
+ }),
50
+ );
51
+ }
52
+
53
+ trace(...data: unknown[]) {
54
+ this.dispatchEvent(
55
+ new CustomEvent(MESSAGE_EVENT, {
56
+ detail: { level: "trace", message: data },
57
+ }),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Track the console messages.
63
+ */
64
+ trackMessages() {
65
+ return new OutputTracker(this, MESSAGE_EVENT);
66
+ }
67
+ }
@@ -0,0 +1,89 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ import { ConfigurableResponses } from "../common/configurable_responses";
4
+
5
+ /**
6
+ * This data object configures the response of a fetch stub call.
7
+ */
8
+ export interface ResponseData {
9
+ /** The HTTP status code. */
10
+ status: number;
11
+
12
+ /** The HTTP status text. */
13
+ statusText: string;
14
+
15
+ /** The optional response body. */
16
+ body?: Blob | object | string | null;
17
+ }
18
+
19
+ /**
20
+ * Create a fetch stub.
21
+ *
22
+ * The stub returns a response from the provided response data or throws an provided error.
23
+ *
24
+ * @param responses A single response or an array of responses.
25
+ * @returns The fetch stub.
26
+ */
27
+ export function createFetchStub(
28
+ responses?: ResponseData | Error | (ResponseData | Error)[],
29
+ ): () => Promise<Response> {
30
+ const configurableResponses = ConfigurableResponses.create(responses);
31
+ return async function () {
32
+ const response = configurableResponses.next();
33
+ if (response instanceof Error) {
34
+ throw response;
35
+ }
36
+
37
+ return new ResponseStub(response) as unknown as Response;
38
+ };
39
+ }
40
+
41
+ class ResponseStub {
42
+ #status: number;
43
+ #statusText: string;
44
+ #body?: Blob | object | string | null;
45
+
46
+ constructor({ status, statusText, body = null }: ResponseData) {
47
+ this.#status = status;
48
+ this.#statusText = statusText;
49
+ this.#body = body;
50
+ }
51
+
52
+ get ok() {
53
+ return this.status >= 200 && this.status < 300;
54
+ }
55
+
56
+ get status() {
57
+ return this.#status;
58
+ }
59
+
60
+ get statusText() {
61
+ return this.#statusText;
62
+ }
63
+
64
+ async blob() {
65
+ if (this.#body == null) {
66
+ return null;
67
+ }
68
+
69
+ if (this.#body instanceof Blob) {
70
+ return this.#body;
71
+ }
72
+
73
+ throw new TypeError("Body is not a Blob.");
74
+ }
75
+
76
+ async json() {
77
+ const json =
78
+ typeof this.#body === "string" ? this.#body : JSON.stringify(this.#body);
79
+ return Promise.resolve(JSON.parse(json));
80
+ }
81
+
82
+ async text() {
83
+ if (this.#body == null) {
84
+ return "";
85
+ }
86
+
87
+ return String(this.#body);
88
+ }
89
+ }
@@ -0,0 +1,50 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ /**
4
+ * An interface for a streaming message client.
5
+ *
6
+ * Emits the following events:
7
+ *
8
+ * - open, {@link Event}
9
+ * - message, {@link MessageEvent}
10
+ * - error, {@link Event}
11
+ * - close, optional {@link CloseEvent}
12
+ *
13
+ * It is used for wrappers around {@link EventSource} and {@link WebSocket}.
14
+ *
15
+ * @see {@link SseClient}
16
+ * @see {@link WebSocketClient}
17
+ */
18
+ export interface MessageClient extends EventTarget {
19
+ /**
20
+ * Return whether the client is connected.
21
+ */
22
+ get isConnected(): boolean;
23
+
24
+ /**
25
+ * Return the server URL.
26
+ */
27
+ get url(): string | undefined;
28
+
29
+ /**
30
+ * Connect to the server.
31
+ *
32
+ * @param url The server URL to connect to.
33
+ */
34
+ connect(url: string | URL): Promise<void>;
35
+
36
+ /**
37
+ * Send a message to the server.
38
+ *
39
+ * This is an optional method for streams with bidirectional communication.
40
+ *
41
+ * @param message The message to send.
42
+ * @param type The optional message type.
43
+ */
44
+ send(message: string, type?: string): Promise<void>;
45
+
46
+ /**
47
+ * Close the connection.
48
+ */
49
+ close(): Promise<void>;
50
+ }
@@ -0,0 +1,7 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ export * from "./console_stub";
4
+ export * from "./fetch_stub";
5
+ export * from "./message_client";
6
+ export * from "./sse_client";
7
+ export * from "./web_socket_client";
@@ -0,0 +1,162 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ import type { MessageClient } from "./message_client";
4
+
5
+ /**
6
+ * A client for the server-sent events protocol.
7
+ */
8
+ export class SseClient extends EventTarget implements MessageClient {
9
+ /**
10
+ * Create an SSE client.
11
+ *
12
+ * @return A new SSE client.
13
+ */
14
+ static create(): SseClient {
15
+ return new SseClient(EventSource);
16
+ }
17
+
18
+ /**
19
+ * Create a nulled SSE client.
20
+ *
21
+ * @return A new SSE client.
22
+ */
23
+ static createNull(): SseClient {
24
+ return new SseClient(EventSourceStub as typeof EventSource);
25
+ }
26
+
27
+ readonly #eventSourceConstructor: typeof EventSource;
28
+
29
+ #eventSource?: EventSource;
30
+
31
+ private constructor(eventSourceConstructor: typeof EventSource) {
32
+ super();
33
+ this.#eventSourceConstructor = eventSourceConstructor;
34
+ }
35
+
36
+ get isConnected(): boolean {
37
+ return this.#eventSource?.readyState === this.#eventSourceConstructor.OPEN;
38
+ }
39
+
40
+ get url(): string | undefined {
41
+ return this.#eventSource?.url;
42
+ }
43
+
44
+ async connect(
45
+ url: string | URL,
46
+ eventName = "message",
47
+ ...otherEvents: string[]
48
+ ): Promise<void> {
49
+ await new Promise<void>((resolve, reject) => {
50
+ if (this.isConnected) {
51
+ reject(new Error("Already connected."));
52
+ return;
53
+ }
54
+
55
+ try {
56
+ this.#eventSource = new this.#eventSourceConstructor(url);
57
+ this.#eventSource.addEventListener("open", (e) => {
58
+ this.#handleOpen(e);
59
+ resolve();
60
+ });
61
+ this.#eventSource.addEventListener(eventName, (e) =>
62
+ this.#handleMessage(e),
63
+ );
64
+ for (const otherEvent of otherEvents) {
65
+ this.#eventSource.addEventListener(otherEvent, (e) =>
66
+ this.#handleMessage(e),
67
+ );
68
+ }
69
+ this.#eventSource.addEventListener("error", (e) =>
70
+ this.#handleError(e),
71
+ );
72
+ } catch (error) {
73
+ reject(error);
74
+ }
75
+ });
76
+ }
77
+
78
+ send(_message: string, _type?: string): Promise<void> {
79
+ throw new Error("Method not implemented.");
80
+ }
81
+
82
+ async close(): Promise<void> {
83
+ await new Promise<void>((resolve, reject) => {
84
+ if (!this.isConnected) {
85
+ resolve();
86
+ return;
87
+ }
88
+
89
+ try {
90
+ this.#eventSource!.close();
91
+ resolve();
92
+ } catch (error) {
93
+ reject(error);
94
+ }
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Simulate a message event from the server.
100
+ *
101
+ * @param message The message to receive.
102
+ * @param eventName The optional event type.
103
+ * @param lastEventId The optional last event ID.
104
+ */
105
+ simulateMessage(
106
+ message: string | number | boolean | object | null,
107
+ eventName = "message",
108
+ lastEventId?: string,
109
+ ) {
110
+ if (typeof message !== "string") {
111
+ message = JSON.stringify(message);
112
+ }
113
+ this.#handleMessage(
114
+ new MessageEvent(eventName, { data: message, lastEventId }),
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Simulate an error event.
120
+ */
121
+ simulateError() {
122
+ this.#handleError(new Event("error"));
123
+ }
124
+
125
+ #handleOpen(event: Event) {
126
+ this.dispatchEvent(new Event(event.type, event));
127
+ }
128
+
129
+ #handleMessage(event: MessageEvent) {
130
+ this.dispatchEvent(
131
+ new MessageEvent(event.type, event as unknown as MessageEventInit),
132
+ );
133
+ }
134
+
135
+ #handleError(event: Event) {
136
+ this.dispatchEvent(new Event(event.type, event));
137
+ }
138
+ }
139
+
140
+ class EventSourceStub extends EventTarget {
141
+ // The constants have to be defined here because Node.js support is currently
142
+ // experimental only.
143
+ static CONNECTING = 0;
144
+ static OPEN = 1;
145
+ static CLOSED = 2;
146
+
147
+ url: string;
148
+ readyState = EventSourceStub.CONNECTING;
149
+
150
+ constructor(url: string | URL) {
151
+ super();
152
+ this.url = url.toString();
153
+ setTimeout(() => {
154
+ this.readyState = EventSourceStub.OPEN;
155
+ this.dispatchEvent(new Event("open"));
156
+ }, 0);
157
+ }
158
+
159
+ close() {
160
+ this.readyState = EventSourceStub.CLOSED;
161
+ }
162
+ }