@lilsnibbi/discord 1.0.1

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,110 @@
1
+ /** A named shutdown phase. Tasks within a phase run concurrently. */
2
+ export interface DiscordShutdownPhase {
3
+ label: string;
4
+ tasks: readonly ((signal: AbortSignal) => unknown | Promise<unknown>)[];
5
+ /** Per-phase deadline; defaults to 1,500ms. */
6
+ timeoutMs?: number;
7
+ }
8
+
9
+ /** Options for bounded, best-effort shutdown. */
10
+ export interface DiscordShutdownOptions {
11
+ /** Total deadline shared by all phases; defaults to 7,000ms. */
12
+ budgetMs?: number;
13
+ /** Receives task failures, phase timeouts, and skipped phases. */
14
+ onError?: (label: string, error: unknown) => void | Promise<void>;
15
+ }
16
+
17
+ /** Reject invalid timer values rather than allowing immediate/overflow timers. */
18
+ export function validateShutdownTimeout(value: number): void {
19
+ if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
20
+ throw new RangeError(
21
+ "Shutdown timeouts must be between 0 and 2147483647ms",
22
+ );
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Runs phases in order, continuing after failures. A timed-out phase receives
28
+ * an aborted signal; tasks must cooperate with cancellation. No process exit
29
+ * or signal handlers are installed. Returns every failure, including timeouts.
30
+ */
31
+ export async function runDiscordShutdown(
32
+ phases: readonly DiscordShutdownPhase[],
33
+ options: DiscordShutdownOptions = {},
34
+ ): Promise<{ label: string; error: unknown }[]> {
35
+ const budget = options.budgetMs ?? 7_000;
36
+ validateShutdownTimeout(budget);
37
+ for (const phase of phases) validateShutdownTimeout(phase.timeoutMs ?? 1_500);
38
+ const deadline = performance.now() + budget;
39
+ const failures: { label: string; error: unknown }[] = [];
40
+ const report = async (label: string, error: unknown) => {
41
+ failures.push({ label, error });
42
+ try {
43
+ const reporter = Promise.resolve(options.onError?.(label, error)).then(
44
+ () => ({ status: "fulfilled" }) as const,
45
+ (reportError: unknown) =>
46
+ ({ status: "rejected", error: reportError }) as const,
47
+ );
48
+ const remaining = deadline - performance.now();
49
+ if (remaining <= 0) {
50
+ void reporter;
51
+ return;
52
+ }
53
+ let timer: ReturnType<typeof setTimeout> | undefined;
54
+ const result = await Promise.race([
55
+ reporter,
56
+ new Promise<{ status: "timed-out" }>((resolve) => {
57
+ timer = setTimeout(() => resolve({ status: "timed-out" }), remaining);
58
+ }),
59
+ ]);
60
+ clearTimeout(timer);
61
+ if (result.status === "rejected") {
62
+ failures.push({ label: `${label}:reporter`, error: result.error });
63
+ } else if (result.status === "timed-out") {
64
+ failures.push({
65
+ label: `${label}:reporter`,
66
+ error: new Error(
67
+ "Shutdown error reporter exceeded the remaining budget",
68
+ ),
69
+ });
70
+ }
71
+ } catch (reportError) {
72
+ failures.push({ label: `${label}:reporter`, error: reportError });
73
+ }
74
+ };
75
+ for (const phase of phases) {
76
+ const remaining = deadline - performance.now();
77
+ if (remaining <= 0) {
78
+ await report(
79
+ phase.label,
80
+ new Error("Shutdown budget exhausted; phase skipped"),
81
+ );
82
+ continue;
83
+ }
84
+ const controller = new AbortController();
85
+ const timeoutMs = Math.min(phase.timeoutMs ?? 1_500, remaining);
86
+ let timer: ReturnType<typeof setTimeout> | undefined;
87
+ const results = await Promise.race([
88
+ Promise.allSettled(
89
+ phase.tasks.map((task) =>
90
+ Promise.resolve().then(() => task(controller.signal)),
91
+ ),
92
+ ),
93
+ new Promise<null>((resolve) => {
94
+ timer = setTimeout(() => resolve(null), timeoutMs);
95
+ }),
96
+ ]);
97
+ clearTimeout(timer);
98
+ if (results === null) {
99
+ const error = new Error(`Shutdown phase timed out after ${timeoutMs}ms`);
100
+ controller.abort(error);
101
+ await report(phase.label, error);
102
+ } else {
103
+ for (const result of results) {
104
+ if (result.status === "rejected")
105
+ await report(phase.label, result.reason);
106
+ }
107
+ }
108
+ }
109
+ return failures;
110
+ }
@@ -0,0 +1,65 @@
1
+ import type { DiscordClient } from "./DiscordClient";
2
+
3
+ /**
4
+ * The shared dependency bag, reachable from any module without threading the
5
+ * client through every constructor.
6
+ *
7
+ * Augment it via module declaration to register your own singletons alongside
8
+ * the client.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * declare module "@lilsnibbi/discord" {
13
+ * interface Container {
14
+ * db: Database;
15
+ * }
16
+ * }
17
+ * ```
18
+ */
19
+ export interface Container {
20
+ /** The live client, assigned by the `DiscordClient` constructor. */
21
+ client: DiscordClient;
22
+ }
23
+
24
+ /**
25
+ * The process-wide {@link Container}.
26
+ *
27
+ * It is a mutable object rather than a re-exported binding so importers always
28
+ * read the current value, and it is empty until a `DiscordClient` is
29
+ * constructed — reach for {@link getClient} if you need that guarded.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { container } from "@lilsnibbi/discord";
34
+ *
35
+ * container.client.log.notif("ready");
36
+ * ```
37
+ */
38
+ export const container = {} as Container;
39
+
40
+ /**
41
+ * Returns the active client, throwing before construction or after destruction.
42
+ *
43
+ * Pass a client class to also check its runtime type and retrieve subclass members.
44
+ * @throws {Error} If no active client exists or its class does not match.
45
+ */
46
+ export function getClient(): DiscordClient;
47
+ export function getClient<C extends DiscordClient>(
48
+ clientType: abstract new (...args: never[]) => C,
49
+ ): C;
50
+ export function getClient(
51
+ clientType?: abstract new (...args: never[]) => DiscordClient,
52
+ ): DiscordClient {
53
+ if (!container.client) {
54
+ throw new Error(
55
+ "No DiscordClient has been constructed yet — the container is empty.",
56
+ );
57
+ }
58
+ if (clientType && !(container.client instanceof clientType)) {
59
+ throw new Error(
60
+ `The active DiscordClient is not an instance of ${clientType.name}`,
61
+ );
62
+ }
63
+
64
+ return container.client;
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Entry point for `@lilsnibbi/discord`.
3
+ *
4
+ * Requires the `discord.js` peer dependency. The client publishes itself to
5
+ * `container`, and the command, event and pagination structures are the pieces
6
+ * a bot registers against it.
7
+ */
8
+ export * from "./container";
9
+ export * from "./DiscordClient";
10
+ export * from "./DiscordCommand";
11
+ export * from "./DiscordEvent";
12
+ export * from "./DiscordPagination";
13
+ export type { DiscordModuleOptions } from "./DiscordModuleLoader";
14
+ export { runDiscordShutdown } from "./DiscordShutdown";
15
+ export type {
16
+ DiscordShutdownPhase,
17
+ DiscordShutdownOptions,
18
+ } from "./DiscordShutdown";