tsdkarc 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.
- package/README.md +244 -0
- package/assets/banner.jpg +0 -0
- package/assets/logo.jpg +0 -0
- package/esm/index.d.ts +175 -0
- package/esm/index.js +214 -0
- package/esm/tsconfig.esm.tsbuildinfo +1 -0
- package/lib/index.d.ts +175 -0
- package/lib/index.js +221 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# <a href="https://arc.tsdk.dev" align="center"><img src="./assets/logo.jpg" align="center" width="30px" height="30px" style="border-radius: 4px;margin-right:4px;" alt="TsdkArc: the Elegant, fully type-safe module composition library" /></a> TsdkArc
|
|
2
|
+
|
|
3
|
+
<a href="https://arc.tsdk.dev">
|
|
4
|
+
<img src="./assets/banner.jpg" width="100%" style="border-radius: 24px" alt="TsdkArc: the Elegant, fully type-safe module composition library" /></a>
|
|
5
|
+
|
|
6
|
+
<div align="center">The Elegant, Fully Type-safe Module Composition Library.
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why `TsdkArc`
|
|
12
|
+
|
|
13
|
+
Application codebases grow, the code become coupled and messy — hard to reuse, hard to share.
|
|
14
|
+
`TsdkArc` lets you compose modules like building blocks, nest them, and share them type safely across projects.
|
|
15
|
+
|
|
16
|
+
In **tsdkrc**, Each module declares what it needs and what it provides. Then call `start([modules])` will resolves the full dependency graph, boots modules in order, and returns a typed context.
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install tsdkarc
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import start, { defineModule } from "tsdkarc";
|
|
26
|
+
|
|
27
|
+
interface ConfigSlice {
|
|
28
|
+
config: { port: number; env: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const configModule = defineModule<ConfigSlice>()({
|
|
32
|
+
name: "config",
|
|
33
|
+
modules: [],
|
|
34
|
+
boot(ctx) {
|
|
35
|
+
ctx.set("config", {
|
|
36
|
+
env: process.env.NODE_ENV ?? "development",
|
|
37
|
+
port: Number(process.env.PORT) || 3000,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Run
|
|
43
|
+
(async () => {
|
|
44
|
+
const app = await start([configModule]);
|
|
45
|
+
console.log(app.ctx.config.port); // 3000
|
|
46
|
+
await app.stop();
|
|
47
|
+
})();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Core Concepts
|
|
53
|
+
|
|
54
|
+
| Term | Description |
|
|
55
|
+
| ----------- | ----------------------------------------------------------------------------------------------- |
|
|
56
|
+
| **Slice** | The shape a module adds to the shared context (`{ key: Type }`) |
|
|
57
|
+
| **Module** | Declares dependencies (`modules`), registers values (`ctx.set`), and optionally tears them down |
|
|
58
|
+
| **Context** | The merged union of all slices — fully typed at each module's boundary |
|
|
59
|
+
|
|
60
|
+
## API Outline
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
defineModule<OwnSlice>()({
|
|
64
|
+
name: string,
|
|
65
|
+
modules: Module[],
|
|
66
|
+
boot?(ctx): void | Promise<void>,
|
|
67
|
+
beforeBoot?(ctx): void | Promise<void>,
|
|
68
|
+
afterBoot?(ctx): void | Promise<void>,
|
|
69
|
+
shutdown?(ctx): void | Promise<void>,
|
|
70
|
+
beforeShutdown?(ctx): void | Promise<void>,
|
|
71
|
+
afterShutdown?(ctx): void | Promise<void>,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
start(modules: Module[], hooks?: {
|
|
75
|
+
beforeBoot?(ctx): void | Promise<void>,
|
|
76
|
+
afterBoot?(ctx): void | Promise<void>,
|
|
77
|
+
beforeShutdown?(ctx): void | Promise<void>,
|
|
78
|
+
afterShutdown?(ctx): void | Promise<void>,
|
|
79
|
+
}): Promise<{ ctx, stop() }>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Dependency Chain
|
|
85
|
+
|
|
86
|
+
Downstream modules declare upstream modules and get their context fully typed.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
interface DbSlice {
|
|
90
|
+
db: Pool;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const dbModule = defineModule<DbSlice>()({
|
|
94
|
+
name: "db",
|
|
95
|
+
modules: [configModule], // ctx.config is typed here
|
|
96
|
+
async boot(ctx) {
|
|
97
|
+
const pool = new Pool({ connectionString: ctx.config.databaseUrl });
|
|
98
|
+
await pool.connect();
|
|
99
|
+
ctx.set("db", pool);
|
|
100
|
+
},
|
|
101
|
+
async shutdown(ctx) {
|
|
102
|
+
await ctx.db.end();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
interface ServerSlice {
|
|
107
|
+
server: http.Server;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const serverModule = defineModule<ServerSlice>()({
|
|
111
|
+
name: "server",
|
|
112
|
+
modules: [configModule, dbModule], // ctx.config + ctx.db both typed
|
|
113
|
+
boot(ctx) {
|
|
114
|
+
ctx.set("server", http.createServer(myHandler));
|
|
115
|
+
},
|
|
116
|
+
afterBoot(ctx) {
|
|
117
|
+
ctx.server.listen(ctx.config.port);
|
|
118
|
+
},
|
|
119
|
+
shutdown(ctx) {
|
|
120
|
+
ctx.server.close();
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const app = await start([serverModule]);
|
|
125
|
+
await app.stop();
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`start()` walks the dependency graph and deduplicates — each module boots exactly once regardless of how many times it appears in `modules` arrays.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Global Hooks
|
|
133
|
+
|
|
134
|
+
Use the second argument to `start()` for process-level concerns that need access to the full context.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const app = await start([serverModule], {
|
|
138
|
+
afterBoot(ctx) {
|
|
139
|
+
process.on("uncaughtException", (err) => console.error(err));
|
|
140
|
+
},
|
|
141
|
+
beforeShutdown(ctx) {
|
|
142
|
+
console.info("shutting down");
|
|
143
|
+
},
|
|
144
|
+
afterEachBoot(ctx) {
|
|
145
|
+
console.info("booting", mod.name);
|
|
146
|
+
},
|
|
147
|
+
beforeEachShutdown(ctx, mod) {
|
|
148
|
+
console.info("shutting down", mod.name);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Patterns
|
|
156
|
+
|
|
157
|
+
**Register anything, not just data.** Functions, class instances, and middleware are all valid context values.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
interface AuthSlice {
|
|
161
|
+
authenticate: (req: Request, res: Response, next: NextFunction) => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const authModule = defineModule<AuthSlice>()({
|
|
165
|
+
name: "auth",
|
|
166
|
+
modules: [],
|
|
167
|
+
boot(ctx) {
|
|
168
|
+
ctx.set("authenticate", (req, res, next) => {
|
|
169
|
+
if (!req.headers.authorization) return res.status(401).end();
|
|
170
|
+
next();
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Compose small modules.** Each module is independently testable and replaceable. Complex wiring is just a chain of dependencies.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// config → db → cache → queue → server
|
|
180
|
+
const app = await start([serverModule]);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Lifecycle
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
beforeBoot → boot → afterBoot → [running] → beforeShutdown → shutdown → afterShutdown
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Hook | Purpose |
|
|
192
|
+
| -------------------- | ------------------------------------------------------------------------------ |
|
|
193
|
+
| `beforeBoot` | Pre-setup, Called once before the first module begins booting. |
|
|
194
|
+
| `boot` | Register values on `ctx` via `ctx.set()` |
|
|
195
|
+
| `afterBoot` | Called once after the last module has finished booting. |
|
|
196
|
+
| `beforeShutdown` | Called once before the first module begins shutting down. |
|
|
197
|
+
| `shutdown` | Release resources |
|
|
198
|
+
| `afterShutdown` | Called once after the last module has finished shutting down. |
|
|
199
|
+
| `beforeEachBoot` | Called before each individual module boots, in boot order. |
|
|
200
|
+
| `afterEachBoot` | Called after each individual module finishes booting, in boot order. |
|
|
201
|
+
| `beforeEachShutdown` | Called before each individual module shuts down, in shutdown order. |
|
|
202
|
+
| `afterEachShutdown` | Called after each individual module finishes shutting down, in shutdown order. |
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## API
|
|
207
|
+
|
|
208
|
+
### `defineModule<Slice>()(config)`
|
|
209
|
+
|
|
210
|
+
| Field | Type | Description |
|
|
211
|
+
| ---------------- | -------------------------- | ----------------------------- |
|
|
212
|
+
| `name` | `string` | Unique module identifier |
|
|
213
|
+
| `description` | `string` | Optional description |
|
|
214
|
+
| `modules` | `Module<Slice>[]` | Declared dependencies |
|
|
215
|
+
| `beforeBoot` | `(ctx) => void \| Promise` | Runs before boot |
|
|
216
|
+
| `boot` | `(ctx) => void \| Promise` | Register values on ctx |
|
|
217
|
+
| `afterBoot` | `(ctx) => void \| Promise` | Runs after all modules booted |
|
|
218
|
+
| `beforeShutdown` | `(ctx) => void \| Promise` | Runs before shutdown |
|
|
219
|
+
| `shutdown` | `(ctx) => void \| Promise` | Release resources |
|
|
220
|
+
| `afterShutdown` | `(ctx) => void \| Promise` | Runs after shutdown |
|
|
221
|
+
|
|
222
|
+
### `start(roots, options?)` → `{ ctx, stop }`
|
|
223
|
+
|
|
224
|
+
| Field | Description |
|
|
225
|
+
| --------- | -------------------------------------- |
|
|
226
|
+
| `roots` | Top-level modules (deps auto-resolved) |
|
|
227
|
+
| `options` | Global lifecycle hooks |
|
|
228
|
+
| `ctx` | Merged, fully-typed context |
|
|
229
|
+
| `stop` | Triggers full shutdown sequence |
|
|
230
|
+
|
|
231
|
+
## Projects You May Also Be Interested In
|
|
232
|
+
|
|
233
|
+
- [xior](https://github.com/suhaotian/xior) - A tiny but powerful fetch wrapper with plugins support and axios-like API
|
|
234
|
+
- [tsdk](https://github.com/tsdk-monorepo/tsdk) - Type-safe API development CLI tool for TypeScript projects
|
|
235
|
+
- [broad-infinite-list](https://github.com/suhaotian/broad-infinite-list) - ⚡ High performance and Bidirectional infinite scrolling list component for React and Vue3
|
|
236
|
+
- [littkk](https://github.com/suhaotian/littkk) - 🧞♂️ Shows and hides UI elements on scroll.
|
|
237
|
+
|
|
238
|
+
## Reporting Issues
|
|
239
|
+
|
|
240
|
+
Found an issue? Please feel free to [create issue](https://github.com/tsdk-monorepo/tsdkarc/issues/new)
|
|
241
|
+
|
|
242
|
+
## Support
|
|
243
|
+
|
|
244
|
+
If you find this project helpful, consider [buying me a coffee](https://github.com/tsdk-monorepo/tsdkarc/stargazers).
|
|
Binary file
|
package/assets/logo.jpg
ADDED
|
Binary file
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tsdkarc.ts
|
|
3
|
+
*
|
|
4
|
+
* Minimal module lifecycle manager.
|
|
5
|
+
*
|
|
6
|
+
* Exports: Module, defineModule, ContextWriter, Logger, LifecycleHooks, StartOptions, start.
|
|
7
|
+
*
|
|
8
|
+
* Type helpers (internal):
|
|
9
|
+
* UnionToIntersection<U> — U1 | U2 | U3 → U1 & U2 & U3
|
|
10
|
+
* SliceOf<M> — extracts S (full context) from Module<S, Sl>
|
|
11
|
+
* MergeSlices<Tuple> — intersects all S from a modules tuple
|
|
12
|
+
* FullContext<Deps, Own> — MergeSlices<Deps> & Own
|
|
13
|
+
*
|
|
14
|
+
* Internal functions use AnyModule (Module<object, object>) at boundaries
|
|
15
|
+
* where generic params are intentionally erased — the runtime does not need them.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* ContextWriter<S, Sl>
|
|
19
|
+
*
|
|
20
|
+
* Reading — direct property access on Readonly<S>: ctx.db, ctx.config
|
|
21
|
+
* Writing — set() restricted to OwnSlice keys only: ctx.set('auth', ...)
|
|
22
|
+
*
|
|
23
|
+
* Readonly<S> prevents ctx.db = ... at the type level.
|
|
24
|
+
* Attempting to write a dep key via set() is also a type error.
|
|
25
|
+
*
|
|
26
|
+
* Note: 'set' is a reserved key — no slice may use 'set' as a property name.
|
|
27
|
+
*/
|
|
28
|
+
export type ContextWriter<S extends object, Sl extends object = S> = Readonly<S> & {
|
|
29
|
+
set<K extends Exclude<keyof Sl, "set">>(key: K, value: Sl[K]): void;
|
|
30
|
+
};
|
|
31
|
+
/** Converts U1 | U2 | U3 into U1 & U2 & U3 via contravariance. */
|
|
32
|
+
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
|
33
|
+
/** Extracts full context S from a Module — so transitive deps propagate upward. */
|
|
34
|
+
type SliceOf<M> = M extends Module<infer S, object> ? S : never;
|
|
35
|
+
/** Merges full context S from all modules in a tuple into one intersection. */
|
|
36
|
+
type MergeSlices<T extends readonly AnyModule[]> = UnionToIntersection<SliceOf<T[number]>> extends object ? UnionToIntersection<SliceOf<T[number]>> : Record<never, never>;
|
|
37
|
+
/** Full context seen by a module = all deps merged context + own slice. */
|
|
38
|
+
type FullContext<Deps extends readonly AnyModule[], Own extends object> = MergeSlices<Deps> & Own;
|
|
39
|
+
/** Opaque alias used at internal boundaries where generic params are erased. */
|
|
40
|
+
type AnyModule = Module<object, object>;
|
|
41
|
+
/**
|
|
42
|
+
* Global lifecycle hooks for start().
|
|
43
|
+
*
|
|
44
|
+
* Each fires exactly once — not per-module:
|
|
45
|
+
* beforeBoot — before the first module boots
|
|
46
|
+
* afterBoot — after the last module has booted
|
|
47
|
+
* beforeShutdown — before the first module shuts down
|
|
48
|
+
* afterShutdown — after the last module has shut down
|
|
49
|
+
*
|
|
50
|
+
* Per-module variants (beforeEach*, afterEach*) fire once per module,
|
|
51
|
+
* in boot/shutdown order, with the current module passed as the second argument.
|
|
52
|
+
*/
|
|
53
|
+
export interface LifecycleHooks<S extends object = Record<never, never>> {
|
|
54
|
+
/** Called once before the first module begins booting. */
|
|
55
|
+
beforeBoot?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
56
|
+
/** Called once after the last module has finished booting. */
|
|
57
|
+
afterBoot?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
58
|
+
/** Called once before the first module begins shutting down. */
|
|
59
|
+
beforeShutdown?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
60
|
+
/** Called once after the last module has finished shutting down. */
|
|
61
|
+
afterShutdown?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
62
|
+
/** Called before each individual module boots, in boot order. */
|
|
63
|
+
beforeEachBoot?(ctx: ContextWriter<S>,
|
|
64
|
+
/** The module about to boot. */
|
|
65
|
+
module: Module<any>): Promise<void> | void;
|
|
66
|
+
/** Called after each individual module finishes booting, in boot order. */
|
|
67
|
+
afterEachBoot?(ctx: ContextWriter<S>,
|
|
68
|
+
/** The module that just finished booting. */
|
|
69
|
+
module: Module<any>): Promise<void> | void;
|
|
70
|
+
/** Called before each individual module shuts down, in shutdown order. */
|
|
71
|
+
beforeEachShutdown?(ctx: ContextWriter<S>,
|
|
72
|
+
/** The module about to shut down. */
|
|
73
|
+
module: Module<any>): Promise<void> | void;
|
|
74
|
+
/** Called after each individual module finishes shutting down, in shutdown order. */
|
|
75
|
+
afterEachShutdown?(ctx: ContextWriter<S>,
|
|
76
|
+
/** The module that just finished shutting down. */
|
|
77
|
+
module: Module<any>): Promise<void> | void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Module<S, Sl>
|
|
81
|
+
*
|
|
82
|
+
* Per-module hooks fire scoped to this module's own boot/shutdown.
|
|
83
|
+
* They receive ContextWriter<S, Sl> — same as boot/shutdown.
|
|
84
|
+
*/
|
|
85
|
+
export interface Module<S extends object, Sl extends object = S> {
|
|
86
|
+
name: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
modules?: AnyModule[];
|
|
89
|
+
boot?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
90
|
+
shutdown?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
91
|
+
/** Called immediately before this module's own boot(). */
|
|
92
|
+
beforeBoot?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
93
|
+
/** Called immediately after this module's own boot(). */
|
|
94
|
+
afterBoot?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
95
|
+
/** Called immediately before this module's own shutdown(). */
|
|
96
|
+
beforeShutdown?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
97
|
+
/** Called immediately after this module's own shutdown(). */
|
|
98
|
+
afterShutdown?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Define a module with full context inferred from its dependency tuple.
|
|
102
|
+
*
|
|
103
|
+
* OwnSlice first — pass only what this module contributes:
|
|
104
|
+
* defineModule<AuthSlice>()({ modules: [db, redis] as const, ... })
|
|
105
|
+
*
|
|
106
|
+
* Deps is inferred from modules — no need to pass it explicitly.
|
|
107
|
+
* Omit OwnSlice entirely if this module contributes nothing to context:
|
|
108
|
+
* defineModule()({ modules: [db] as const, ... })
|
|
109
|
+
*
|
|
110
|
+
* Inside boot/shutdown:
|
|
111
|
+
* ctx.db — read dep directly (Readonly<S>)
|
|
112
|
+
* ctx.set('auth') — write own slice only
|
|
113
|
+
*
|
|
114
|
+
* @param def module definition
|
|
115
|
+
*/
|
|
116
|
+
export declare function defineModule<OwnSlice extends object = Record<never, never>>(): <const Deps extends readonly AnyModule[] = []>(def: {
|
|
117
|
+
name: string;
|
|
118
|
+
description?: string;
|
|
119
|
+
modules?: Deps;
|
|
120
|
+
boot?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
121
|
+
shutdown?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
122
|
+
beforeBoot?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
123
|
+
afterBoot?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
124
|
+
beforeShutdown?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
125
|
+
afterShutdown?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
126
|
+
}) => Module<FullContext<Deps, OwnSlice>, OwnSlice>;
|
|
127
|
+
/**
|
|
128
|
+
* Options for start(). Flat intersection — no nesting required:
|
|
129
|
+
* start([db], { log, afterBoot: async (ctx) => ... })
|
|
130
|
+
*
|
|
131
|
+
* S is inferred from the roots tuple — no explicit type param needed.
|
|
132
|
+
*/
|
|
133
|
+
export type StartOptions<S extends object = Record<never, never>> = LifecycleHooks<S>;
|
|
134
|
+
/**
|
|
135
|
+
* Boot all modules reachable from roots, in dependency order.
|
|
136
|
+
* Deduplicates by name across the entire tree — pass only roots.
|
|
137
|
+
* Returns stop() for graceful shutdown. stop() is idempotent.
|
|
138
|
+
*
|
|
139
|
+
* Context type is inferred from the roots tuple automatically.
|
|
140
|
+
*
|
|
141
|
+
* Global hook order:
|
|
142
|
+
* beforeBoot → [each: beforeBoot → boot → afterBoot] → afterBoot
|
|
143
|
+
* beforeShutdown → [each: beforeShutdown → shutdown → afterShutdown] → afterShutdown
|
|
144
|
+
*
|
|
145
|
+
* Global hooks do NOT fire during rollback — rollback is error recovery.
|
|
146
|
+
*
|
|
147
|
+
* Throws if:
|
|
148
|
+
* - same name resolves to two different instances (collision)
|
|
149
|
+
* - a circular dependency is detected
|
|
150
|
+
* - any boot() fails (already-booted modules are rolled back)
|
|
151
|
+
*
|
|
152
|
+
* @param roots root Module instances — tree walked recursively
|
|
153
|
+
* @param options optional log + global lifecycle hooks
|
|
154
|
+
*/
|
|
155
|
+
export default function start<const Roots extends readonly AnyModule[]>(roots: Roots, options?: StartOptions<MergeSlices<Roots>>): Promise<{
|
|
156
|
+
stop(): Promise<void>;
|
|
157
|
+
ctx: MergeSlices<Roots>;
|
|
158
|
+
}>;
|
|
159
|
+
/**
|
|
160
|
+
* Walk the module tree from roots, dedupe by name, topoSort.
|
|
161
|
+
* Throws on name collision (same name, different instance).
|
|
162
|
+
*/
|
|
163
|
+
export declare function resolveModules(roots: AnyModule[]): AnyModule[];
|
|
164
|
+
/**
|
|
165
|
+
* DFS topological sort over the registry.
|
|
166
|
+
* Throws on circular dependency.
|
|
167
|
+
*/
|
|
168
|
+
export declare function topoSort(registry: Map<string, AnyModule>): AnyModule[];
|
|
169
|
+
/**
|
|
170
|
+
* Shut down already-booted modules in reverse order.
|
|
171
|
+
* Best-effort: logs errors, never throws.
|
|
172
|
+
* Global beforeShutdown/afterShutdown do NOT fire — rollback is error recovery.
|
|
173
|
+
*/
|
|
174
|
+
export declare function rollback(booted: AnyModule[], modWriter: ContextWriter<object, object>): Promise<void>;
|
|
175
|
+
export {};
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tsdkarc.ts
|
|
3
|
+
*
|
|
4
|
+
* Minimal module lifecycle manager.
|
|
5
|
+
*
|
|
6
|
+
* Exports: Module, defineModule, ContextWriter, Logger, LifecycleHooks, StartOptions, start.
|
|
7
|
+
*
|
|
8
|
+
* Type helpers (internal):
|
|
9
|
+
* UnionToIntersection<U> — U1 | U2 | U3 → U1 & U2 & U3
|
|
10
|
+
* SliceOf<M> — extracts S (full context) from Module<S, Sl>
|
|
11
|
+
* MergeSlices<Tuple> — intersects all S from a modules tuple
|
|
12
|
+
* FullContext<Deps, Own> — MergeSlices<Deps> & Own
|
|
13
|
+
*
|
|
14
|
+
* Internal functions use AnyModule (Module<object, object>) at boundaries
|
|
15
|
+
* where generic params are intentionally erased — the runtime does not need them.
|
|
16
|
+
*/
|
|
17
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
18
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
19
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
20
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
21
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
22
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
23
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// defineModule
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Define a module with full context inferred from its dependency tuple.
|
|
31
|
+
*
|
|
32
|
+
* OwnSlice first — pass only what this module contributes:
|
|
33
|
+
* defineModule<AuthSlice>()({ modules: [db, redis] as const, ... })
|
|
34
|
+
*
|
|
35
|
+
* Deps is inferred from modules — no need to pass it explicitly.
|
|
36
|
+
* Omit OwnSlice entirely if this module contributes nothing to context:
|
|
37
|
+
* defineModule()({ modules: [db] as const, ... })
|
|
38
|
+
*
|
|
39
|
+
* Inside boot/shutdown:
|
|
40
|
+
* ctx.db — read dep directly (Readonly<S>)
|
|
41
|
+
* ctx.set('auth') — write own slice only
|
|
42
|
+
*
|
|
43
|
+
* @param def module definition
|
|
44
|
+
*/
|
|
45
|
+
export function defineModule() {
|
|
46
|
+
return function (def) {
|
|
47
|
+
return def;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// start
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Boot all modules reachable from roots, in dependency order.
|
|
55
|
+
* Deduplicates by name across the entire tree — pass only roots.
|
|
56
|
+
* Returns stop() for graceful shutdown. stop() is idempotent.
|
|
57
|
+
*
|
|
58
|
+
* Context type is inferred from the roots tuple automatically.
|
|
59
|
+
*
|
|
60
|
+
* Global hook order:
|
|
61
|
+
* beforeBoot → [each: beforeBoot → boot → afterBoot] → afterBoot
|
|
62
|
+
* beforeShutdown → [each: beforeShutdown → shutdown → afterShutdown] → afterShutdown
|
|
63
|
+
*
|
|
64
|
+
* Global hooks do NOT fire during rollback — rollback is error recovery.
|
|
65
|
+
*
|
|
66
|
+
* Throws if:
|
|
67
|
+
* - same name resolves to two different instances (collision)
|
|
68
|
+
* - a circular dependency is detected
|
|
69
|
+
* - any boot() fails (already-booted modules are rolled back)
|
|
70
|
+
*
|
|
71
|
+
* @param roots root Module instances — tree walked recursively
|
|
72
|
+
* @param options optional log + global lifecycle hooks
|
|
73
|
+
*/
|
|
74
|
+
export default function start(roots_1) {
|
|
75
|
+
return __awaiter(this, arguments, void 0, function* (roots, options = {}) {
|
|
76
|
+
var _a, _b, _c;
|
|
77
|
+
const { beforeBoot, afterBoot, beforeShutdown, afterShutdown, beforeEachBoot, afterEachBoot, beforeEachShutdown, afterEachShutdown, } = options;
|
|
78
|
+
const ctx = {};
|
|
79
|
+
const writer = makeWriter(ctx);
|
|
80
|
+
const modWriter = writer;
|
|
81
|
+
const booted = [];
|
|
82
|
+
let stopped = false;
|
|
83
|
+
const sorted = resolveModules([...roots]);
|
|
84
|
+
function doStop() {
|
|
85
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86
|
+
var _a, _b, _c;
|
|
87
|
+
if (stopped)
|
|
88
|
+
return;
|
|
89
|
+
stopped = true;
|
|
90
|
+
yield (beforeShutdown === null || beforeShutdown === void 0 ? void 0 : beforeShutdown(writer));
|
|
91
|
+
for (const mod of [...booted].reverse()) {
|
|
92
|
+
try {
|
|
93
|
+
yield (beforeEachShutdown === null || beforeEachShutdown === void 0 ? void 0 : beforeEachShutdown(writer, mod));
|
|
94
|
+
yield ((_a = mod.beforeShutdown) === null || _a === void 0 ? void 0 : _a.call(mod, modWriter));
|
|
95
|
+
yield ((_b = mod.shutdown) === null || _b === void 0 ? void 0 : _b.call(mod, modWriter));
|
|
96
|
+
yield (afterEachShutdown === null || afterEachShutdown === void 0 ? void 0 : afterEachShutdown(writer, mod));
|
|
97
|
+
yield ((_c = mod.afterShutdown) === null || _c === void 0 ? void 0 : _c.call(mod, modWriter));
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new Error(`Module "${mod.name}" stop failed: ${errorMessage(err)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
yield (afterShutdown === null || afterShutdown === void 0 ? void 0 : afterShutdown(writer));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
yield (beforeBoot === null || beforeBoot === void 0 ? void 0 : beforeBoot(writer));
|
|
107
|
+
for (const mod of sorted) {
|
|
108
|
+
try {
|
|
109
|
+
yield (beforeEachBoot === null || beforeEachBoot === void 0 ? void 0 : beforeEachBoot(writer, mod));
|
|
110
|
+
yield ((_a = mod.beforeBoot) === null || _a === void 0 ? void 0 : _a.call(mod, modWriter));
|
|
111
|
+
yield ((_b = mod.boot) === null || _b === void 0 ? void 0 : _b.call(mod, modWriter));
|
|
112
|
+
yield (afterEachBoot === null || afterEachBoot === void 0 ? void 0 : afterEachBoot(writer, mod));
|
|
113
|
+
yield ((_c = mod.afterBoot) === null || _c === void 0 ? void 0 : _c.call(mod, modWriter));
|
|
114
|
+
booted.push(mod);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
stopped = true;
|
|
118
|
+
yield rollback(booted, modWriter);
|
|
119
|
+
throw new Error(`Module "${mod.name}" boot failed: ${errorMessage(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
yield (afterBoot === null || afterBoot === void 0 ? void 0 : afterBoot(writer));
|
|
123
|
+
return { stop: doStop, ctx: modWriter };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Internal helpers
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
/**
|
|
130
|
+
* Walk the module tree from roots, dedupe by name, topoSort.
|
|
131
|
+
* Throws on name collision (same name, different instance).
|
|
132
|
+
*/
|
|
133
|
+
export function resolveModules(roots) {
|
|
134
|
+
const registry = new Map();
|
|
135
|
+
function collect(mod) {
|
|
136
|
+
var _a;
|
|
137
|
+
const existing = registry.get(mod.name);
|
|
138
|
+
if (existing) {
|
|
139
|
+
if (existing !== mod) {
|
|
140
|
+
throw new Error(`Module name collision: "${mod.name}" registered with two different instances`);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
registry.set(mod.name, mod);
|
|
145
|
+
for (const dep of (_a = mod.modules) !== null && _a !== void 0 ? _a : [])
|
|
146
|
+
collect(dep);
|
|
147
|
+
}
|
|
148
|
+
for (const root of roots)
|
|
149
|
+
collect(root);
|
|
150
|
+
return topoSort(registry);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* DFS topological sort over the registry.
|
|
154
|
+
* Throws on circular dependency.
|
|
155
|
+
*/
|
|
156
|
+
export function topoSort(registry) {
|
|
157
|
+
const visited = new Set();
|
|
158
|
+
const inStack = new Set();
|
|
159
|
+
const order = [];
|
|
160
|
+
function visit(name) {
|
|
161
|
+
var _a;
|
|
162
|
+
if (visited.has(name))
|
|
163
|
+
return;
|
|
164
|
+
if (inStack.has(name))
|
|
165
|
+
throw new Error(`Circular dependency detected at: "${name}"`);
|
|
166
|
+
const mod = registry.get(name);
|
|
167
|
+
if (!mod)
|
|
168
|
+
return;
|
|
169
|
+
inStack.add(name);
|
|
170
|
+
for (const dep of (_a = mod.modules) !== null && _a !== void 0 ? _a : [])
|
|
171
|
+
visit(dep.name);
|
|
172
|
+
inStack.delete(name);
|
|
173
|
+
visited.add(name);
|
|
174
|
+
order.push(mod);
|
|
175
|
+
}
|
|
176
|
+
for (const name of registry.keys())
|
|
177
|
+
visit(name);
|
|
178
|
+
return order;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Shut down already-booted modules in reverse order.
|
|
182
|
+
* Best-effort: logs errors, never throws.
|
|
183
|
+
* Global beforeShutdown/afterShutdown do NOT fire — rollback is error recovery.
|
|
184
|
+
*/
|
|
185
|
+
export function rollback(booted, modWriter) {
|
|
186
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
187
|
+
var _a, _b, _c;
|
|
188
|
+
for (const mod of [...booted].reverse()) {
|
|
189
|
+
try {
|
|
190
|
+
yield ((_a = mod.beforeShutdown) === null || _a === void 0 ? void 0 : _a.call(mod, modWriter));
|
|
191
|
+
yield ((_b = mod.shutdown) === null || _b === void 0 ? void 0 : _b.call(mod, modWriter));
|
|
192
|
+
yield ((_c = mod.afterShutdown) === null || _c === void 0 ? void 0 : _c.call(mod, modWriter));
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
throw new Error(`Module "${mod.name}" rollback_shutdown_failed: ${errorMessage(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* ContextWriter backed by a plain Record.
|
|
202
|
+
* Direct property access for reads (via Proxy), set() for writes.
|
|
203
|
+
* Cast to ContextWriter<S> at the start() boundary.
|
|
204
|
+
*/
|
|
205
|
+
function makeWriter(ctx) {
|
|
206
|
+
return Object.assign(ctx, {
|
|
207
|
+
set(key, value) {
|
|
208
|
+
ctx[key] = value;
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function errorMessage(err) {
|
|
213
|
+
return err instanceof Error ? err.message : String(err);
|
|
214
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"fileNames":["../node_modules/typescript/lib/lib.es5.d.ts","../node_modules/typescript/lib/lib.es2015.d.ts","../node_modules/typescript/lib/lib.dom.d.ts","../node_modules/typescript/lib/lib.es2015.core.d.ts","../node_modules/typescript/lib/lib.es2015.collection.d.ts","../node_modules/typescript/lib/lib.es2015.generator.d.ts","../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../node_modules/typescript/lib/lib.es2015.promise.d.ts","../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../node_modules/typescript/lib/lib.decorators.d.ts","../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../src/index.ts","../node_modules/@types/deep-eql/index.d.ts","../node_modules/assertion-error/index.d.ts","../node_modules/@types/chai/index.d.ts","../node_modules/@types/estree/index.d.ts"],"fileIdsList":[[16,17]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"66d99e5eea1d583803f80816a7e5b264c4bac636deb853502918e2044ef1a5f7","signature":"5d3723bdf23914654d36eda00c37a16b499e89400a515fbecdb9b3741429f4e0"},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1}],"root":[15],"options":{"declaration":true,"downlevelIteration":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"module":5,"noImplicitAny":true,"outDir":"./","skipLibCheck":true,"strict":true,"strictPropertyInitialization":false,"target":2},"referencedMap":[[18,1]],"version":"5.9.3"}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tsdkarc.ts
|
|
3
|
+
*
|
|
4
|
+
* Minimal module lifecycle manager.
|
|
5
|
+
*
|
|
6
|
+
* Exports: Module, defineModule, ContextWriter, Logger, LifecycleHooks, StartOptions, start.
|
|
7
|
+
*
|
|
8
|
+
* Type helpers (internal):
|
|
9
|
+
* UnionToIntersection<U> — U1 | U2 | U3 → U1 & U2 & U3
|
|
10
|
+
* SliceOf<M> — extracts S (full context) from Module<S, Sl>
|
|
11
|
+
* MergeSlices<Tuple> — intersects all S from a modules tuple
|
|
12
|
+
* FullContext<Deps, Own> — MergeSlices<Deps> & Own
|
|
13
|
+
*
|
|
14
|
+
* Internal functions use AnyModule (Module<object, object>) at boundaries
|
|
15
|
+
* where generic params are intentionally erased — the runtime does not need them.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* ContextWriter<S, Sl>
|
|
19
|
+
*
|
|
20
|
+
* Reading — direct property access on Readonly<S>: ctx.db, ctx.config
|
|
21
|
+
* Writing — set() restricted to OwnSlice keys only: ctx.set('auth', ...)
|
|
22
|
+
*
|
|
23
|
+
* Readonly<S> prevents ctx.db = ... at the type level.
|
|
24
|
+
* Attempting to write a dep key via set() is also a type error.
|
|
25
|
+
*
|
|
26
|
+
* Note: 'set' is a reserved key — no slice may use 'set' as a property name.
|
|
27
|
+
*/
|
|
28
|
+
export type ContextWriter<S extends object, Sl extends object = S> = Readonly<S> & {
|
|
29
|
+
set<K extends Exclude<keyof Sl, "set">>(key: K, value: Sl[K]): void;
|
|
30
|
+
};
|
|
31
|
+
/** Converts U1 | U2 | U3 into U1 & U2 & U3 via contravariance. */
|
|
32
|
+
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
|
33
|
+
/** Extracts full context S from a Module — so transitive deps propagate upward. */
|
|
34
|
+
type SliceOf<M> = M extends Module<infer S, object> ? S : never;
|
|
35
|
+
/** Merges full context S from all modules in a tuple into one intersection. */
|
|
36
|
+
type MergeSlices<T extends readonly AnyModule[]> = UnionToIntersection<SliceOf<T[number]>> extends object ? UnionToIntersection<SliceOf<T[number]>> : Record<never, never>;
|
|
37
|
+
/** Full context seen by a module = all deps merged context + own slice. */
|
|
38
|
+
type FullContext<Deps extends readonly AnyModule[], Own extends object> = MergeSlices<Deps> & Own;
|
|
39
|
+
/** Opaque alias used at internal boundaries where generic params are erased. */
|
|
40
|
+
type AnyModule = Module<object, object>;
|
|
41
|
+
/**
|
|
42
|
+
* Global lifecycle hooks for start().
|
|
43
|
+
*
|
|
44
|
+
* Each fires exactly once — not per-module:
|
|
45
|
+
* beforeBoot — before the first module boots
|
|
46
|
+
* afterBoot — after the last module has booted
|
|
47
|
+
* beforeShutdown — before the first module shuts down
|
|
48
|
+
* afterShutdown — after the last module has shut down
|
|
49
|
+
*
|
|
50
|
+
* Per-module variants (beforeEach*, afterEach*) fire once per module,
|
|
51
|
+
* in boot/shutdown order, with the current module passed as the second argument.
|
|
52
|
+
*/
|
|
53
|
+
export interface LifecycleHooks<S extends object = Record<never, never>> {
|
|
54
|
+
/** Called once before the first module begins booting. */
|
|
55
|
+
beforeBoot?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
56
|
+
/** Called once after the last module has finished booting. */
|
|
57
|
+
afterBoot?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
58
|
+
/** Called once before the first module begins shutting down. */
|
|
59
|
+
beforeShutdown?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
60
|
+
/** Called once after the last module has finished shutting down. */
|
|
61
|
+
afterShutdown?(ctx: ContextWriter<S>): Promise<void> | void;
|
|
62
|
+
/** Called before each individual module boots, in boot order. */
|
|
63
|
+
beforeEachBoot?(ctx: ContextWriter<S>,
|
|
64
|
+
/** The module about to boot. */
|
|
65
|
+
module: Module<any>): Promise<void> | void;
|
|
66
|
+
/** Called after each individual module finishes booting, in boot order. */
|
|
67
|
+
afterEachBoot?(ctx: ContextWriter<S>,
|
|
68
|
+
/** The module that just finished booting. */
|
|
69
|
+
module: Module<any>): Promise<void> | void;
|
|
70
|
+
/** Called before each individual module shuts down, in shutdown order. */
|
|
71
|
+
beforeEachShutdown?(ctx: ContextWriter<S>,
|
|
72
|
+
/** The module about to shut down. */
|
|
73
|
+
module: Module<any>): Promise<void> | void;
|
|
74
|
+
/** Called after each individual module finishes shutting down, in shutdown order. */
|
|
75
|
+
afterEachShutdown?(ctx: ContextWriter<S>,
|
|
76
|
+
/** The module that just finished shutting down. */
|
|
77
|
+
module: Module<any>): Promise<void> | void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Module<S, Sl>
|
|
81
|
+
*
|
|
82
|
+
* Per-module hooks fire scoped to this module's own boot/shutdown.
|
|
83
|
+
* They receive ContextWriter<S, Sl> — same as boot/shutdown.
|
|
84
|
+
*/
|
|
85
|
+
export interface Module<S extends object, Sl extends object = S> {
|
|
86
|
+
name: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
modules?: AnyModule[];
|
|
89
|
+
boot?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
90
|
+
shutdown?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
91
|
+
/** Called immediately before this module's own boot(). */
|
|
92
|
+
beforeBoot?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
93
|
+
/** Called immediately after this module's own boot(). */
|
|
94
|
+
afterBoot?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
95
|
+
/** Called immediately before this module's own shutdown(). */
|
|
96
|
+
beforeShutdown?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
97
|
+
/** Called immediately after this module's own shutdown(). */
|
|
98
|
+
afterShutdown?(ctx: ContextWriter<S, Sl>): Promise<void> | void;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Define a module with full context inferred from its dependency tuple.
|
|
102
|
+
*
|
|
103
|
+
* OwnSlice first — pass only what this module contributes:
|
|
104
|
+
* defineModule<AuthSlice>()({ modules: [db, redis] as const, ... })
|
|
105
|
+
*
|
|
106
|
+
* Deps is inferred from modules — no need to pass it explicitly.
|
|
107
|
+
* Omit OwnSlice entirely if this module contributes nothing to context:
|
|
108
|
+
* defineModule()({ modules: [db] as const, ... })
|
|
109
|
+
*
|
|
110
|
+
* Inside boot/shutdown:
|
|
111
|
+
* ctx.db — read dep directly (Readonly<S>)
|
|
112
|
+
* ctx.set('auth') — write own slice only
|
|
113
|
+
*
|
|
114
|
+
* @param def module definition
|
|
115
|
+
*/
|
|
116
|
+
export declare function defineModule<OwnSlice extends object = Record<never, never>>(): <const Deps extends readonly AnyModule[] = []>(def: {
|
|
117
|
+
name: string;
|
|
118
|
+
description?: string;
|
|
119
|
+
modules?: Deps;
|
|
120
|
+
boot?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
121
|
+
shutdown?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
122
|
+
beforeBoot?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
123
|
+
afterBoot?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
124
|
+
beforeShutdown?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
125
|
+
afterShutdown?(ctx: ContextWriter<FullContext<Deps, OwnSlice>, OwnSlice>): Promise<void> | void;
|
|
126
|
+
}) => Module<FullContext<Deps, OwnSlice>, OwnSlice>;
|
|
127
|
+
/**
|
|
128
|
+
* Options for start(). Flat intersection — no nesting required:
|
|
129
|
+
* start([db], { log, afterBoot: async (ctx) => ... })
|
|
130
|
+
*
|
|
131
|
+
* S is inferred from the roots tuple — no explicit type param needed.
|
|
132
|
+
*/
|
|
133
|
+
export type StartOptions<S extends object = Record<never, never>> = LifecycleHooks<S>;
|
|
134
|
+
/**
|
|
135
|
+
* Boot all modules reachable from roots, in dependency order.
|
|
136
|
+
* Deduplicates by name across the entire tree — pass only roots.
|
|
137
|
+
* Returns stop() for graceful shutdown. stop() is idempotent.
|
|
138
|
+
*
|
|
139
|
+
* Context type is inferred from the roots tuple automatically.
|
|
140
|
+
*
|
|
141
|
+
* Global hook order:
|
|
142
|
+
* beforeBoot → [each: beforeBoot → boot → afterBoot] → afterBoot
|
|
143
|
+
* beforeShutdown → [each: beforeShutdown → shutdown → afterShutdown] → afterShutdown
|
|
144
|
+
*
|
|
145
|
+
* Global hooks do NOT fire during rollback — rollback is error recovery.
|
|
146
|
+
*
|
|
147
|
+
* Throws if:
|
|
148
|
+
* - same name resolves to two different instances (collision)
|
|
149
|
+
* - a circular dependency is detected
|
|
150
|
+
* - any boot() fails (already-booted modules are rolled back)
|
|
151
|
+
*
|
|
152
|
+
* @param roots root Module instances — tree walked recursively
|
|
153
|
+
* @param options optional log + global lifecycle hooks
|
|
154
|
+
*/
|
|
155
|
+
export default function start<const Roots extends readonly AnyModule[]>(roots: Roots, options?: StartOptions<MergeSlices<Roots>>): Promise<{
|
|
156
|
+
stop(): Promise<void>;
|
|
157
|
+
ctx: MergeSlices<Roots>;
|
|
158
|
+
}>;
|
|
159
|
+
/**
|
|
160
|
+
* Walk the module tree from roots, dedupe by name, topoSort.
|
|
161
|
+
* Throws on name collision (same name, different instance).
|
|
162
|
+
*/
|
|
163
|
+
export declare function resolveModules(roots: AnyModule[]): AnyModule[];
|
|
164
|
+
/**
|
|
165
|
+
* DFS topological sort over the registry.
|
|
166
|
+
* Throws on circular dependency.
|
|
167
|
+
*/
|
|
168
|
+
export declare function topoSort(registry: Map<string, AnyModule>): AnyModule[];
|
|
169
|
+
/**
|
|
170
|
+
* Shut down already-booted modules in reverse order.
|
|
171
|
+
* Best-effort: logs errors, never throws.
|
|
172
|
+
* Global beforeShutdown/afterShutdown do NOT fire — rollback is error recovery.
|
|
173
|
+
*/
|
|
174
|
+
export declare function rollback(booted: AnyModule[], modWriter: ContextWriter<object, object>): Promise<void>;
|
|
175
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* tsdkarc.ts
|
|
4
|
+
*
|
|
5
|
+
* Minimal module lifecycle manager.
|
|
6
|
+
*
|
|
7
|
+
* Exports: Module, defineModule, ContextWriter, Logger, LifecycleHooks, StartOptions, start.
|
|
8
|
+
*
|
|
9
|
+
* Type helpers (internal):
|
|
10
|
+
* UnionToIntersection<U> — U1 | U2 | U3 → U1 & U2 & U3
|
|
11
|
+
* SliceOf<M> — extracts S (full context) from Module<S, Sl>
|
|
12
|
+
* MergeSlices<Tuple> — intersects all S from a modules tuple
|
|
13
|
+
* FullContext<Deps, Own> — MergeSlices<Deps> & Own
|
|
14
|
+
*
|
|
15
|
+
* Internal functions use AnyModule (Module<object, object>) at boundaries
|
|
16
|
+
* where generic params are intentionally erased — the runtime does not need them.
|
|
17
|
+
*/
|
|
18
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
19
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
20
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
21
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
22
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
23
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
24
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.defineModule = defineModule;
|
|
29
|
+
exports.default = start;
|
|
30
|
+
exports.resolveModules = resolveModules;
|
|
31
|
+
exports.topoSort = topoSort;
|
|
32
|
+
exports.rollback = rollback;
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// defineModule
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Define a module with full context inferred from its dependency tuple.
|
|
38
|
+
*
|
|
39
|
+
* OwnSlice first — pass only what this module contributes:
|
|
40
|
+
* defineModule<AuthSlice>()({ modules: [db, redis] as const, ... })
|
|
41
|
+
*
|
|
42
|
+
* Deps is inferred from modules — no need to pass it explicitly.
|
|
43
|
+
* Omit OwnSlice entirely if this module contributes nothing to context:
|
|
44
|
+
* defineModule()({ modules: [db] as const, ... })
|
|
45
|
+
*
|
|
46
|
+
* Inside boot/shutdown:
|
|
47
|
+
* ctx.db — read dep directly (Readonly<S>)
|
|
48
|
+
* ctx.set('auth') — write own slice only
|
|
49
|
+
*
|
|
50
|
+
* @param def module definition
|
|
51
|
+
*/
|
|
52
|
+
function defineModule() {
|
|
53
|
+
return function (def) {
|
|
54
|
+
return def;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// start
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* Boot all modules reachable from roots, in dependency order.
|
|
62
|
+
* Deduplicates by name across the entire tree — pass only roots.
|
|
63
|
+
* Returns stop() for graceful shutdown. stop() is idempotent.
|
|
64
|
+
*
|
|
65
|
+
* Context type is inferred from the roots tuple automatically.
|
|
66
|
+
*
|
|
67
|
+
* Global hook order:
|
|
68
|
+
* beforeBoot → [each: beforeBoot → boot → afterBoot] → afterBoot
|
|
69
|
+
* beforeShutdown → [each: beforeShutdown → shutdown → afterShutdown] → afterShutdown
|
|
70
|
+
*
|
|
71
|
+
* Global hooks do NOT fire during rollback — rollback is error recovery.
|
|
72
|
+
*
|
|
73
|
+
* Throws if:
|
|
74
|
+
* - same name resolves to two different instances (collision)
|
|
75
|
+
* - a circular dependency is detected
|
|
76
|
+
* - any boot() fails (already-booted modules are rolled back)
|
|
77
|
+
*
|
|
78
|
+
* @param roots root Module instances — tree walked recursively
|
|
79
|
+
* @param options optional log + global lifecycle hooks
|
|
80
|
+
*/
|
|
81
|
+
function start(roots_1) {
|
|
82
|
+
return __awaiter(this, arguments, void 0, function* (roots, options = {}) {
|
|
83
|
+
var _a, _b, _c;
|
|
84
|
+
const { beforeBoot, afterBoot, beforeShutdown, afterShutdown, beforeEachBoot, afterEachBoot, beforeEachShutdown, afterEachShutdown, } = options;
|
|
85
|
+
const ctx = {};
|
|
86
|
+
const writer = makeWriter(ctx);
|
|
87
|
+
const modWriter = writer;
|
|
88
|
+
const booted = [];
|
|
89
|
+
let stopped = false;
|
|
90
|
+
const sorted = resolveModules([...roots]);
|
|
91
|
+
function doStop() {
|
|
92
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
93
|
+
var _a, _b, _c;
|
|
94
|
+
if (stopped)
|
|
95
|
+
return;
|
|
96
|
+
stopped = true;
|
|
97
|
+
yield (beforeShutdown === null || beforeShutdown === void 0 ? void 0 : beforeShutdown(writer));
|
|
98
|
+
for (const mod of [...booted].reverse()) {
|
|
99
|
+
try {
|
|
100
|
+
yield (beforeEachShutdown === null || beforeEachShutdown === void 0 ? void 0 : beforeEachShutdown(writer, mod));
|
|
101
|
+
yield ((_a = mod.beforeShutdown) === null || _a === void 0 ? void 0 : _a.call(mod, modWriter));
|
|
102
|
+
yield ((_b = mod.shutdown) === null || _b === void 0 ? void 0 : _b.call(mod, modWriter));
|
|
103
|
+
yield (afterEachShutdown === null || afterEachShutdown === void 0 ? void 0 : afterEachShutdown(writer, mod));
|
|
104
|
+
yield ((_c = mod.afterShutdown) === null || _c === void 0 ? void 0 : _c.call(mod, modWriter));
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw new Error(`Module "${mod.name}" stop failed: ${errorMessage(err)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
yield (afterShutdown === null || afterShutdown === void 0 ? void 0 : afterShutdown(writer));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
yield (beforeBoot === null || beforeBoot === void 0 ? void 0 : beforeBoot(writer));
|
|
114
|
+
for (const mod of sorted) {
|
|
115
|
+
try {
|
|
116
|
+
yield (beforeEachBoot === null || beforeEachBoot === void 0 ? void 0 : beforeEachBoot(writer, mod));
|
|
117
|
+
yield ((_a = mod.beforeBoot) === null || _a === void 0 ? void 0 : _a.call(mod, modWriter));
|
|
118
|
+
yield ((_b = mod.boot) === null || _b === void 0 ? void 0 : _b.call(mod, modWriter));
|
|
119
|
+
yield (afterEachBoot === null || afterEachBoot === void 0 ? void 0 : afterEachBoot(writer, mod));
|
|
120
|
+
yield ((_c = mod.afterBoot) === null || _c === void 0 ? void 0 : _c.call(mod, modWriter));
|
|
121
|
+
booted.push(mod);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
stopped = true;
|
|
125
|
+
yield rollback(booted, modWriter);
|
|
126
|
+
throw new Error(`Module "${mod.name}" boot failed: ${errorMessage(err)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
yield (afterBoot === null || afterBoot === void 0 ? void 0 : afterBoot(writer));
|
|
130
|
+
return { stop: doStop, ctx: modWriter };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Internal helpers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
/**
|
|
137
|
+
* Walk the module tree from roots, dedupe by name, topoSort.
|
|
138
|
+
* Throws on name collision (same name, different instance).
|
|
139
|
+
*/
|
|
140
|
+
function resolveModules(roots) {
|
|
141
|
+
const registry = new Map();
|
|
142
|
+
function collect(mod) {
|
|
143
|
+
var _a;
|
|
144
|
+
const existing = registry.get(mod.name);
|
|
145
|
+
if (existing) {
|
|
146
|
+
if (existing !== mod) {
|
|
147
|
+
throw new Error(`Module name collision: "${mod.name}" registered with two different instances`);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
registry.set(mod.name, mod);
|
|
152
|
+
for (const dep of (_a = mod.modules) !== null && _a !== void 0 ? _a : [])
|
|
153
|
+
collect(dep);
|
|
154
|
+
}
|
|
155
|
+
for (const root of roots)
|
|
156
|
+
collect(root);
|
|
157
|
+
return topoSort(registry);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* DFS topological sort over the registry.
|
|
161
|
+
* Throws on circular dependency.
|
|
162
|
+
*/
|
|
163
|
+
function topoSort(registry) {
|
|
164
|
+
const visited = new Set();
|
|
165
|
+
const inStack = new Set();
|
|
166
|
+
const order = [];
|
|
167
|
+
function visit(name) {
|
|
168
|
+
var _a;
|
|
169
|
+
if (visited.has(name))
|
|
170
|
+
return;
|
|
171
|
+
if (inStack.has(name))
|
|
172
|
+
throw new Error(`Circular dependency detected at: "${name}"`);
|
|
173
|
+
const mod = registry.get(name);
|
|
174
|
+
if (!mod)
|
|
175
|
+
return;
|
|
176
|
+
inStack.add(name);
|
|
177
|
+
for (const dep of (_a = mod.modules) !== null && _a !== void 0 ? _a : [])
|
|
178
|
+
visit(dep.name);
|
|
179
|
+
inStack.delete(name);
|
|
180
|
+
visited.add(name);
|
|
181
|
+
order.push(mod);
|
|
182
|
+
}
|
|
183
|
+
for (const name of registry.keys())
|
|
184
|
+
visit(name);
|
|
185
|
+
return order;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Shut down already-booted modules in reverse order.
|
|
189
|
+
* Best-effort: logs errors, never throws.
|
|
190
|
+
* Global beforeShutdown/afterShutdown do NOT fire — rollback is error recovery.
|
|
191
|
+
*/
|
|
192
|
+
function rollback(booted, modWriter) {
|
|
193
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
194
|
+
var _a, _b, _c;
|
|
195
|
+
for (const mod of [...booted].reverse()) {
|
|
196
|
+
try {
|
|
197
|
+
yield ((_a = mod.beforeShutdown) === null || _a === void 0 ? void 0 : _a.call(mod, modWriter));
|
|
198
|
+
yield ((_b = mod.shutdown) === null || _b === void 0 ? void 0 : _b.call(mod, modWriter));
|
|
199
|
+
yield ((_c = mod.afterShutdown) === null || _c === void 0 ? void 0 : _c.call(mod, modWriter));
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
throw new Error(`Module "${mod.name}" rollback_shutdown_failed: ${errorMessage(err)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* ContextWriter backed by a plain Record.
|
|
209
|
+
* Direct property access for reads (via Proxy), set() for writes.
|
|
210
|
+
* Cast to ContextWriter<S> at the start() boundary.
|
|
211
|
+
*/
|
|
212
|
+
function makeWriter(ctx) {
|
|
213
|
+
return Object.assign(ctx, {
|
|
214
|
+
set(key, value) {
|
|
215
|
+
ctx[key] = value;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function errorMessage(err) {
|
|
220
|
+
return err instanceof Error ? err.message : String(err);
|
|
221
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tsdkarc",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Elegant and Fully type-safe module composition library",
|
|
5
|
+
"repository": "tsdk-monorepo/tsdkarc",
|
|
6
|
+
"bugs": "https://github.com/tsdk-monorepo/tsdkarc/issues",
|
|
7
|
+
"homepage": "https://www.npmjs.com/package/tsdkarc",
|
|
8
|
+
"main": "lib/index.js",
|
|
9
|
+
"module": "esm/index.js",
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"lib/*",
|
|
13
|
+
"esm/*",
|
|
14
|
+
"assets",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"typesafe",
|
|
19
|
+
"tsdk",
|
|
20
|
+
"dependency injection",
|
|
21
|
+
"di"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT"
|
|
24
|
+
}
|