@rhythmjs/rhythm 0.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.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @rhythmjs/rhythm
2
+
3
+ The core composition kernel `@rhythmjs/router` and `@rhythmjs/cli` are built on. Framework-agnostic — no router, no HTTP layer, and never will be.
4
+
5
+ ## Concepts
6
+
7
+ - **Onion middleware** — `use()` wraps downstream steps, running code before _and_ after `next()`.
8
+ - **Providers** — `provide()` registers a value or async factory that resolves once and is injected into every request's context. A returned key prefixed with `#` (e.g. `"#close"`) stays out of context but is still passed in full to `dispose()`.
9
+ - **Encapsulated modules** — `register()` mounts a child `Rhythm`; its context stays sealed unless you explicitly export fields from it.
10
+ - **Readonly context** — the context passed to middleware is deeply readonly at the type level; state only changes via `next(extra)`, or through a value branded with the `RhythmMutable` symbol (how `RhythmRouter`/`RhythmCli`'s response objects stay mutable).
11
+
12
+ ## Example
13
+
14
+ ```ts
15
+ import { Rhythm } from "@rhythmjs/rhythm";
16
+
17
+ const app = new Rhythm<{ userId: string }>()
18
+ .provide(() => ({ config: { serviceName: "greeter" } }))
19
+ .provide((deps) => ({
20
+ logger: { info: (msg: string) => console.log(`[${deps.config.serviceName}] ${msg}`) },
21
+ }))
22
+ .use(async (ctx, next) => {
23
+ const startedAt = Date.now();
24
+ await next();
25
+ ctx.logger.info(`handled in ${Date.now() - startedAt}ms`);
26
+ })
27
+ .use((ctx) => {
28
+ ctx.logger.info(`hello, ${ctx.userId}`);
29
+ });
30
+
31
+ await app.run({ userId: "u1" });
32
+ await app.teardown();
33
+ ```
34
+
35
+ `provide()` factories run once, in declaration order, each receiving everything resolved so far via `deps`. `use()` middleware runs on every `run()` call, in onion order.
36
+
37
+ ### Module registration
38
+
39
+ ```ts
40
+ const authModule = new Rhythm<{ userId: string }>({ name: "auth" })
41
+ .provide(
42
+ () => ({ db: connectToUserDb() }),
43
+ (db) => db.close(),
44
+ )
45
+ .use(async (ctx, next) => {
46
+ await next({ user: ctx.db.findUser(ctx.userId) });
47
+ });
48
+
49
+ const app = new Rhythm<{ userId: string }>()
50
+ .register(authModule, (result) => ({ user: result.user })) // only `user` crosses back
51
+ .use((ctx) => console.log(`hello, ${ctx.user.name}`));
52
+ ```
53
+
54
+ `register()` folds the child's `setup()`/`teardown()` into the parent's lifecycle.
55
+
56
+ ## API
57
+
58
+ - `new Rhythm<TInput>(options?)` — creates a pipeline; `options.name`/`options.type` label errors from `register()`.
59
+ - `.use(fn: (ctx, next) => Promise<void> | void)` — add an onion middleware step.
60
+ - `.provide(factory: (deps) => TValue | Promise<TValue>, dispose?)` — register a provider; resolved once, disposed in reverse order on `teardown()`.
61
+ - `.register(other: Rhythm, exportValue?)` — mount a child module; sealed by default, opt in via `exportValue`.
62
+ - `.run(input)` — runs `setup()` if needed, dispatches `input` through the middleware chain.
63
+ - `.callback()` — returns the cached, reusable `(input) => Promise<TContext>` handler `run()` uses internally.
64
+ - `.middleware()` — returns this instance as a plain middleware, for flat mounting into a parent via `.use()` instead of `.register()` (what `RhythmRouter.routes()`/`RhythmCli.commands()` build on).
65
+ - `.setup()` — resolves all providers, cascading into registered modules. Idempotent; retryable on failure.
66
+ - `.teardown()` — disposes all providers in reverse order, cascading into registered modules.
67
+ - `compose(middleware[])` — the standalone Koa-style onion dispatcher `Rhythm` is built on.
@@ -0,0 +1,30 @@
1
+ //#region src/rhythm.d.ts
2
+ export declare const RhythmMutable: unique symbol;
3
+ export type DeepReadonly<T> = T extends ((...args: any[]) => any) ? T : T extends ReadonlyArray<infer U> ? readonly DeepReadonly<U>[] : T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> : T extends Set<infer U> ? ReadonlySet<DeepReadonly<U>> : T extends {
4
+ readonly [RhythmMutable]: true;
5
+ } ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]>; } : T;
6
+ export type OmitHashKeys<T> = { [K in keyof T as K extends `#${string}` ? never : K]: T[K]; };
7
+ export type NextFn<TContext extends object> = {
8
+ (): Promise<DeepReadonly<TContext>>;
9
+ <TExtra extends object>(extra: TExtra): Promise<DeepReadonly<TContext & TExtra>>;
10
+ };
11
+ export type Middleware<TContext extends object> = (ctx: DeepReadonly<TContext>, next: NextFn<TContext>) => Promise<void> | void;
12
+ export declare function compose<TContext extends object>(middleware: Middleware<TContext>[]): (context: TContext, next?: NextFn<TContext>) => Promise<TContext>;
13
+ export interface RhythmOptions {
14
+ name?: string;
15
+ type?: string;
16
+ [key: string]: unknown;
17
+ }
18
+ export declare class Rhythm<TInput extends object = {}, TContext extends object = TInput, TProviders extends object = {}> {
19
+ #private;
20
+ constructor(options?: RhythmOptions);
21
+ use<TExtra extends object = {}>(fn: Middleware<TContext>): Rhythm<TInput, TContext & TExtra, TProviders>;
22
+ provide<TValue extends object>(factory: (deps: DeepReadonly<TProviders>) => TValue | Promise<TValue>, dispose?: (value: TValue) => void | Promise<void>): Rhythm<TInput, TContext & OmitHashKeys<TValue>, TProviders & OmitHashKeys<TValue>>;
23
+ register<TRegInput extends object, TRegContext extends object, TExported extends object = {}>(other: Rhythm<TRegInput, TRegContext, any> & (TContext extends TRegInput ? unknown : never), exportValue?: (result: DeepReadonly<TRegContext>) => TExported): Rhythm<TInput, TContext & TExported, TProviders>;
24
+ setup(): Promise<void>;
25
+ teardown(): Promise<void>;
26
+ callback(): (input: TInput) => Promise<TContext>;
27
+ run(input: TInput): Promise<TContext>;
28
+ middleware(): Middleware<TContext>;
29
+ }
30
+ //#endregion
package/dist/rhythm.js ADDED
@@ -0,0 +1,123 @@
1
+ //#region src/rhythm.ts
2
+ const RhythmMutable = Symbol("RhythmMutable");
3
+ function compose(middleware) {
4
+ if (!Array.isArray(middleware)) throw new TypeError("Middleware stack must be an array!");
5
+ for (const fn of middleware) if (typeof fn !== "function") throw new TypeError("Middleware must be composed of functions!");
6
+ return function(context, next) {
7
+ let index = -1;
8
+ return dispatch(0);
9
+ function dispatch(i) {
10
+ if (i <= index) return Promise.reject(/* @__PURE__ */ new Error("next() called multiple times"));
11
+ index = i;
12
+ const fn = i === middleware.length ? next : middleware[i];
13
+ if (!fn) return Promise.resolve(context);
14
+ const dispatchNext = ((extra) => {
15
+ if (extra) Object.assign(context, extra);
16
+ return dispatch(i + 1);
17
+ });
18
+ try {
19
+ const call = fn;
20
+ return Promise.resolve(call(context, dispatchNext)).then(() => context);
21
+ } catch (err) {
22
+ return Promise.reject(err);
23
+ }
24
+ }
25
+ };
26
+ }
27
+ var Rhythm = class {
28
+ #middleware = [];
29
+ #options;
30
+ #providers = [];
31
+ #setupPromise = null;
32
+ #providedCache = null;
33
+ #composed = null;
34
+ #callbackFn = null;
35
+ constructor(options = {}) {
36
+ this.#options = options;
37
+ }
38
+ use(fn) {
39
+ if (typeof fn !== "function") throw new TypeError("middleware must be a function!");
40
+ this.#middleware.push(fn);
41
+ this.#invalidateCallback();
42
+ return this;
43
+ }
44
+ provide(factory, dispose) {
45
+ this.#providers.push({
46
+ factory,
47
+ dispose
48
+ });
49
+ return this;
50
+ }
51
+ register(other, exportValue) {
52
+ const module = other;
53
+ this.#providers.push({
54
+ factory: async () => {
55
+ await module.setup();
56
+ return {};
57
+ },
58
+ dispose: () => module.teardown()
59
+ });
60
+ this.#middleware.push(async (ctx, next) => {
61
+ let result;
62
+ try {
63
+ result = await module.run(ctx);
64
+ } catch (cause) {
65
+ const { type = "module", name = "anonymous" } = module.#options;
66
+ throw new Error(`registered ${type} "${name}" failed`, { cause });
67
+ }
68
+ await next(exportValue ? exportValue(result) : {});
69
+ });
70
+ this.#invalidateCallback();
71
+ return this;
72
+ }
73
+ #invalidateCallback() {
74
+ this.#composed = null;
75
+ this.#callbackFn = null;
76
+ }
77
+ setup() {
78
+ if (!this.#setupPromise) this.#setupPromise = this.#resolveProviders().catch((err) => {
79
+ this.#setupPromise = null;
80
+ throw err;
81
+ });
82
+ return this.#setupPromise;
83
+ }
84
+ async #resolveProviders() {
85
+ const resolved = {};
86
+ for (const entry of this.#providers) {
87
+ entry.resolved = await entry.factory(resolved);
88
+ for (const [key, value] of Object.entries(entry.resolved)) if (!key.startsWith("#")) resolved[key] = value;
89
+ }
90
+ this.#providedCache = resolved;
91
+ }
92
+ async teardown() {
93
+ for (const entry of [...this.#providers].reverse()) await entry.dispose?.(entry.resolved);
94
+ }
95
+ callback() {
96
+ if (!this.#callbackFn) {
97
+ if (!this.#composed) this.#composed = compose(this.#middleware);
98
+ const fn = this.#composed;
99
+ this.#callbackFn = async (input) => {
100
+ await this.setup();
101
+ return fn({
102
+ ...this.#providedCache,
103
+ ...input
104
+ });
105
+ };
106
+ }
107
+ return this.#callbackFn;
108
+ }
109
+ run(input) {
110
+ return this.callback()(input);
111
+ }
112
+ middleware() {
113
+ return async (ctx, next) => {
114
+ await this.setup();
115
+ if (!this.#composed) this.#composed = compose(this.#middleware);
116
+ const context = ctx;
117
+ Object.assign(context, this.#providedCache);
118
+ await this.#composed(context, next);
119
+ };
120
+ }
121
+ };
122
+ //#endregion
123
+ export { Rhythm, RhythmMutable, compose };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@rhythmjs/rhythm",
3
+ "version": "0.0.1",
4
+ "description": "A minimal, type-safe onion-middleware and provider composition kernel.",
5
+ "license": "ISC",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rhythmjs/rhythm.git",
9
+ "directory": "packages/rhythm"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/rhythm.d.ts",
19
+ "default": "./dist/rhythm.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.6.3",
28
+ "typescript": "^7.0.2",
29
+ "vite-plus": "^1.0.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=20.19.0"
33
+ },
34
+ "scripts": {
35
+ "build": "vp pack",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vp test"
38
+ }
39
+ }