@rhythmjs/rhythm 0.0.16 → 0.0.17

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 CHANGED
@@ -1,92 +1,130 @@
1
1
  # @rhythmjs/rhythm
2
2
 
3
- The composition kernel at the core of Rhythm, the Bun-native backend framework, the piece `@rhythmjs/router` and `@rhythmjs/cli` are built on. It gives an application its structure (onion middleware, startup-time context, encapsulated modules, all checked at compile time) and deliberately nothing else: no router, no HTTP layer, and never will be.
3
+ The composition kernel at the core of Rhythm, the Bun-native backend framework, the piece `@rhythmjs/router` and `@rhythmjs/cli` are built on. It gives an application its structure (startup-time setup, request-time onion middleware, encapsulated modules, all checked at compile time) and deliberately nothing else: no router, no HTTP layer, and never will be.
4
4
 
5
5
  ## Concepts
6
6
 
7
- - **Onion middleware**: `use()` wraps downstream steps, running code before _and_ after `next()`.
8
- - **Startup vs request time**: Rhythm only handles request time. Anything created at startup (a DB connection, a config object) is created by you, before serving, and handed to the app by assigning to `app.context`. Rhythm has no setup or teardown phase; you close what you opened.
9
- - **Startup context**: `app.context` is a plain object typed by the second type parameter, `Rhythm<TInput, TStartup>`. Everything assigned to it is on every request context. Declare the shape once; assignments and reads are then checked.
10
- - **Encapsulated modules**: `register()` mounts a child `Rhythm`; its context stays sealed unless you explicitly export fields from it.
11
- - **Readonly context**: the context passed to middleware is deeply readonly at the type level; `next()` takes no arguments (pure Koa style), so state only changes via `derive()` or startup `context`, or through a value branded with the `RhythmMutable` symbol (how `RhythmRouter`/`RhythmCli`'s response objects stay mutable).
7
+ Rhythm separates **when** something happens:
12
8
 
13
- ## Example
9
+ - **Startup time: `register()`.** Runs a callback once, immediately, against the app's shared startup context. Use it to create what lives for the whole process (a DB connection, config) and, optionally, how to close it. `stop()` runs the cleanups in reverse order.
10
+ - **Request time: `use()`.** Adds an onion middleware step that runs on every call of the handler, before _and_ after `next()`.
11
+
12
+ Four small composable functions plug into those two methods, each for one purpose:
13
+
14
+ | Function | Goes in | Purpose |
15
+ | ------------------------ | ---------- | ----------------------------------------------------------------------- |
16
+ | `decorate(fn)` | `register` | Add typed fields to the startup context. |
17
+ | `derive(fn)` | `use` | Add typed fields to the request context, then continue. |
18
+ | `mount(plugin, cond?)` | `use` | Run another pipeline (a `Rhythm`, router, CLI) for each request. |
19
+ | `include(module, pick?)` | `register` | Run a `Rhythm` module once at startup and pull selected fields from it. |
20
+
21
+ The context is a plain object, and it is **strictly typed**: `Rhythm<S, I, D>` tracks the startup fields added by `register` (`S`), the input the caller must supply per call (`I`), and the request fields added by `derive` (`D`). Types grow as you chain and nothing is declared twice. Reading a field that was never added is a compile error, a field added by `derive` is not visible to `register`, and a field is only visible to the steps after the one that added it.
14
22
 
15
23
  ```ts
16
- import { Rhythm } from "@rhythmjs/rhythm";
24
+ new Rhythm()
25
+ .use((ctx) => ctx.user) // error: not added yet
26
+ .use(derive(() => ({ user: { name: "ada" } })))
27
+ .use((ctx) => ctx.user.name) // string
28
+ .use((ctx) => ctx.nope); // error: never added
29
+ ```
30
+
31
+ To add a field, use `decorate` or `derive`; plain assignment to an unknown field is rejected on purpose.
17
32
 
18
- const app = new Rhythm<{ userId: string }, { logger: { info(msg: string): void } }>()
33
+ ## Example
34
+
35
+ ```ts
36
+ import { Rhythm, decorate, derive } from "@rhythmjs/rhythm";
37
+
38
+ const app = new Rhythm({ name: "app" })
39
+ .register(
40
+ decorate(() => ({ startedAt: Date.now() })), // startup: runs now
41
+ (ctx) => console.log(`stopped after ${Date.now() - ctx.startedAt}ms`), // cleanup: runs on stop()
42
+ )
43
+ .use(derive(() => ({ requestId: crypto.randomUUID() }))) // request: runs on every call
19
44
  .use(async (ctx, next) => {
20
- const startedAt = Date.now();
45
+ console.log(`[${ctx.requestId}] up for ${Date.now() - ctx.startedAt}ms`);
21
46
  await next();
22
- ctx.logger.info(`handled in ${Date.now() - startedAt}ms`);
23
- })
24
- .use((ctx) => {
25
- ctx.logger.info(`hello, ${ctx.userId}`);
26
47
  });
27
48
 
28
- app.context.logger = { info: (msg) => console.log(`[greeter] ${msg}`) };
29
-
30
- await app.run({ userId: "u1" });
49
+ const handle = app.callback();
50
+ await handle(); // one request
51
+ await app.stop(); // shut down
31
52
  ```
32
53
 
33
- On each `run()`, the middleware chain executes in registration order, onion-style.
54
+ ## Startup: `register()`
34
55
 
35
- ### Startup values
56
+ `register(callback, cleanup?)` calls `callback(ctx, app)` right away with the shared startup context. Return a promise and the first request waits for it. `cleanup(ctx, app)` is optional and runs on `stop()`; if several cleanups throw, `stop()` still runs all of them and rejects with an `AggregateError`. `Rhythm` is also `AsyncDisposable`, so `await using app = ...` stops it for you.
36
57
 
37
58
  ```ts
38
- const app = new Rhythm<{ userId: string }, { db: Db }>() // second parameter: the startup shape
39
- .register(usersModule); // usersModule: Rhythm<{ userId: string; db: Db }> sees db
40
-
41
- app.context.db = await createDb(); // startup time: yours to create and close
42
- app.context.db = 1; // type error: not a Db
43
- app.context.cache = x; // type error: not in the declared shape
59
+ const app = new Rhythm().register(
60
+ decorate(async () => ({ db: await connect() })),
61
+ (ctx) => ctx.db.close(),
62
+ );
44
63
  ```
45
64
 
46
- TypeScript can't infer a type from a later property assignment, so the shape is declared once on the instance. `ctx.db` is then typed in every middleware, and `register()` rejects, at compile time, a module that requires context the parent's input and startup shape don't provide.
65
+ `decorate(fn)` is the typed way to add fields at startup: the returned object is merged into the startup context and `ctx.db` is typed for everything after it. A plain callback works too when you only need a side effect.
47
66
 
48
- A registered module inherits its parent's context and can add its own through its own `module.context`. Those values are visible inside the module and to modules it registers, never to its parent. Per-request input wins over a startup value with the same key. Values are read when a request runs, so assign before serving; nothing checks that every declared key was assigned.
67
+ ## Request: `use()` and `derive()`
49
68
 
50
- ### Extending the context
69
+ `use(middleware)` takes `(ctx, next) => unknown`. `next()` takes no arguments; to add fields to the context use `derive()`, which runs your function (sync or async), merges the result, and calls `next()` for you. Every call of the handler gets a fresh copy of the startup context, so request-time changes never leak between requests.
51
70
 
52
- `next()` accepts no parameters: middleware cannot pass values downstream through it. To add fields to the context, use `derive()`, which runs your function, merges the returned fields into the context, then calls `next()` for you. The new fields are inferred and visible to everything chained after it.
71
+ ```ts
72
+ const app = new Rhythm().use(derive((ctx) => ({ user: lookup(ctx.userId) }))).use((ctx) => console.log(ctx.user.name));
73
+ ```
74
+
75
+ ### Typed input
76
+
77
+ The second type parameter is input the caller must pass to the handler on each call. It is checked at compile time: `callback()` returns a function that requires `I` when it has required fields and makes it optional otherwise.
53
78
 
54
79
  ```ts
55
- import { Rhythm, derive } from "@rhythmjs/rhythm";
80
+ const app = new Rhythm<{}, { userId: string }>().use((ctx) => console.log(ctx.userId));
56
81
 
57
- const app = new Rhythm<{ userId: string }>()
58
- .use(derive((ctx) => ({ user: { id: ctx.userId, name: "Ada" } })))
59
- .use((ctx) => console.log(ctx.user.name));
82
+ await app.callback()({ userId: "u1" });
83
+ await app.callback()(); // type error: userId is required
60
84
  ```
61
85
 
62
- Keys prefixed with `#` are dropped from the context. For long-lived resources, create them at startup and assign them to `app.context`.
86
+ ## Composing pipelines
87
+
88
+ ### `mount(plugin, condition?)`: request time
63
89
 
64
- ### Module registration
90
+ Runs another pipeline against the current request, then continues with `next()`. The optional `condition(ctx)` decides per request whether it runs.
65
91
 
66
92
  ```ts
67
- import { Rhythm, derive } from "@rhythmjs/rhythm";
93
+ app.use(mount(routes)); // a RhythmRouter, a RhythmCli, or another Rhythm
94
+ app.use(mount(metrics, () => Bun.env.METRICS !== "off"));
95
+ ```
68
96
 
69
- const authModule = new Rhythm<{ userId: string }, { db: Db }>({ name: "auth" }).use(
70
- derive((ctx) => ({ user: ctx.db.findUser(ctx.userId) })),
97
+ A mounted `Rhythm` module gets a copy of the context, so what it derives stays sealed inside it; routers and CLIs work on the request context itself (params, response). `mount` checks, at compile time, that the parent's context can supply the plugin's required input (`RhythmRouter<I>` and `RhythmCli<I>` declare theirs the same way). A mounted plugin's own derived fields never leak into the parent's type.
98
+
99
+ ### `include(module, pick?)`: startup time
100
+
101
+ Runs a `Rhythm` module's pipeline once, at registration, with the parent's startup context as input, and registers the module's `stop()` as a cleanup of the parent. `pick(child)` copies chosen fields from the module's finished context into the parent's startup context (typed); without it nothing crosses back.
102
+
103
+ ```ts
104
+ const database = new Rhythm({ name: "database", type: "service" }).register(
105
+ decorate(async () => ({ db: await connect() })),
106
+ (ctx) => ctx.db.close(),
71
107
  );
72
- authModule.context.db = connectToUserDb();
73
108
 
74
- const app = new Rhythm<{ userId: string }>()
75
- .register(authModule, (result) => ({ user: result.user })) // only `user` crosses back
76
- .use((ctx) => console.log(`hello, ${ctx.user.name}`));
109
+ const app = new Rhythm().register(include(database, (child) => ({ db: child.db })));
77
110
  ```
78
111
 
79
- The child runs in place: if it ends the chain without calling `next()`, the parent stops there.
112
+ ## Errors, names and sources
113
+
114
+ - **Named failures.** `new Rhythm({ name, type })` (also accepted by `RhythmRouter` and `RhythmCli`) labels the plugin. A failure inside a mounted or included plugin is rethrown as `mounted service "billing" failed` / `included module "db" failed` with the original error as `cause`. Errors raised downstream of the mount are not attributed to the plugin. Without options the label is `module "anonymous"`.
115
+ - **Sources.** `mount()` and `include()` tag themselves with the plugin they wrap. `pipeline.sources` lists the leaf plugins (routers, CLIs, anything tagged with `withSource(fn, source)`) below it, with `Rhythm` modules expanded in place, and `pipeline.parent` is the pipeline a plugin was adopted by. Tooling such as OpenAPI generation or help output uses this to find what an app is made of.
80
116
 
81
117
  ## API
82
118
 
83
- - `new Rhythm<TInput, TStartup>(options?)`: creates a pipeline; `options.name`/`options.type` label errors from `register()`.
84
- - `.use(fn: (ctx, next) => Promise<void> | void, condition?: Condition)`: add an onion middleware step. With a second callback, the step runs only when `condition(ctx)` returns true and otherwise falls through to `next()`; a conditional `derive()` does not extend the context type. `next()` takes no arguments; to extend the context pass a `derive()` middleware.
85
- - `derive(fn: (ctx) => TExtra | Promise<TExtra>)`: middleware that merges `fn`'s result into the context and continues; the typed way to add fields.
86
- - `.context`: the startup values object, typed by `Rhythm<TInput, TStartup>`. Assign to it before serving; every request context carries it. Inherited by registered modules, never by the parent.
87
- - `.register(other: Rhythm, exportValue?)`: mount a child `Rhythm` module; sealed by default, opt in via `exportValue`. Controllers (`RhythmRouter`, `RhythmCli`) are not modules; they mount via `.use()` instead.
88
- - `.run(input)`: dispatches `input` through the middleware chain.
89
- - `.callback()`: returns the cached, reusable `(input) => Promise<TContext>` handler `run()` uses internally.
90
- - `.middleware()`: returns this instance as a plain middleware, for flat mounting into a parent via `.use()` instead of `.register()`.
91
- - `.parent` / `.sources`: the module this one was registered into, and the tagged sources (routers, clis, any extension) below it in order, with registered modules expanded in place. Read lazily, once the app is assembled. Tag your own middleware with `withSource(fn, source)` from `@rhythmjs/rhythm/source`.
92
- - `compose(middleware[])`: the standalone Koa-style onion dispatcher `Rhythm` is built on.
119
+ - `new Rhythm<S, I, D>(options?)`: startup, input and derived context types (all inferred as you chain, only `I` is usually written by hand); `options.name` / `options.type` label failures.
120
+ - `.register(callback, cleanup?)`: startup step; returns the app with its type extended when given `decorate()`/`include()`.
121
+ - `.use(middleware)`: request step; `derive()` extends the type.
122
+ - `.callback()`: returns the reusable `(input?) => Promise<ctx>` handler (input required when `I` has required fields). Built from the middleware registered so far.
123
+ - `.stop()` / `[Symbol.asyncDispose]()`: run the cleanups in reverse order.
124
+ - `.sources` / `.parent` / `.options`: introspection, available on every `Pipeline`.
125
+ - `decorate(fn)`, `derive(fn)`, `mount(plugin, condition?)`, `include(module, pick?)`: the composables above.
126
+ - `Pipeline<C>`: abstract base shared by `Rhythm`, `RhythmRouter` and `RhythmCli`: `use()`, `sources`, `parent`, `options`, and a protected `chain()`. Extend it to build your own controller.
127
+ - `compose(middleware[])`: the standalone Koa-style onion dispatcher everything is built on.
128
+ - `withSource(fn, source)` / `sourceOf(fn)`: tag and read a middleware's source.
129
+
130
+ Each function also has its own entry point (`@rhythmjs/rhythm/derive`, `/decorate`, `/mount`, `/include`, `/compose`, `/pipeline`, `/source`, `/types`); the root export re-exports them all.
package/dist/compose.d.ts CHANGED
@@ -1,11 +1,2 @@
1
- import type { Condition, DeriveMiddleware, Middleware, NextFn } from "./types";
2
- type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
3
- type ContextOf<M> = M extends Middleware<infer C> ? C : never;
4
- type ExtraOf<M> = M extends DeriveMiddleware<any, infer E> ? E : {};
5
- export type ComposedMiddleware<TContext extends object, TExtra extends object> = ((context: TContext, next?: NextFn<TContext>) => Promise<TContext>) & Middleware<TContext> & {
6
- readonly "~derive": TExtra;
7
- };
8
- export declare function gate<TContext extends object>(fn: Middleware<TContext>, condition: Condition<TContext>): Middleware<TContext>;
9
- export declare function compose<const TMiddleware extends readonly Middleware<any>[]>(middleware: TMiddleware): ComposedMiddleware<UnionToIntersection<ContextOf<TMiddleware[number]>> & {}, UnionToIntersection<ExtraOf<TMiddleware[number]>> & {}>;
10
- export declare function compose<TContext extends object>(middleware: Middleware<TContext>[]): (context: TContext, next?: NextFn<TContext>) => Promise<TContext>;
11
- export {};
1
+ import type { Middleware, Next } from "./types";
2
+ export declare function compose<T>(middleware: Middleware<T>[]): (ctx: T, next?: Next) => Promise<void>;
package/dist/compose.js CHANGED
@@ -1,9 +1,7 @@
1
1
  // @bun
2
2
  import {
3
- gate2,
4
3
  compose2
5
- } from "./rhythm-4by5yd5a.js";
4
+ } from "./rhythm-5w3dm83b.js";
6
5
  export {
7
- compose2 as compose,
8
- gate2 as gate
6
+ compose2 as compose
9
7
  };
@@ -0,0 +1,2 @@
1
+ import type { ExtensionRegister } from "./types";
2
+ export declare function decorate<U extends object, T extends object = {}>(factory: (ctx: T) => U | Promise<U>): ExtensionRegister<T, U>;
@@ -0,0 +1,7 @@
1
+ // @bun
2
+ import {
3
+ decorate2
4
+ } from "./rhythm-k0635rcp.js";
5
+ export {
6
+ decorate2 as decorate
7
+ };
@@ -0,0 +1,2 @@
1
+ import type { ExtensionMiddleware } from "./types";
2
+ export declare function derive<U extends object, T extends object = {}>(factory: (ctx: T) => U | Promise<U>): ExtensionMiddleware<T, U>;
package/dist/derive.js ADDED
@@ -0,0 +1,7 @@
1
+ // @bun
2
+ import {
3
+ derive2
4
+ } from "./rhythm-08xqt5pf.js";
5
+ export {
6
+ derive2 as derive
7
+ };
@@ -0,0 +1,4 @@
1
+ import type { PipelineOptions } from "./types";
2
+ export declare function wrapFailure(verb: "mounted" | "included", plugin: {
3
+ readonly options?: PipelineOptions;
4
+ }, cause: unknown): Error;
@@ -0,0 +1,4 @@
1
+ import type { Rhythm } from "./rhythm";
2
+ import type { ExtensionRegister, RegisterCallback } from "./types";
3
+ export declare function include<S extends object, I extends object, D extends object, T extends object = {}, U extends object = {}>(plugin: Rhythm<S, I, D> & (T extends I ? unknown : never), select: (child: S & I & D) => U): ExtensionRegister<T, U>;
4
+ export declare function include<I extends object = {}, T extends object = {}>(plugin: Rhythm<any, I, any> & (T extends I ? unknown : never)): RegisterCallback<T>;
@@ -0,0 +1,8 @@
1
+ // @bun
2
+ import {
3
+ include2
4
+ } from "./rhythm-bq68je0h.js";
5
+ import"./rhythm-7z989406.js";
6
+ export {
7
+ include2 as include
8
+ };
@@ -0,0 +1,2 @@
1
+ import type { Middleware, Mountable } from "./types";
2
+ export declare function mount<T extends object = {}, I extends object = any>(plugin: Mountable<I> & (T extends I ? unknown : never), condition?: (ctx: T) => boolean): Middleware<T>;
package/dist/mount.js ADDED
@@ -0,0 +1,8 @@
1
+ // @bun
2
+ import {
3
+ mount2
4
+ } from "./rhythm-bhjtktgq.js";
5
+ import"./rhythm-7z989406.js";
6
+ export {
7
+ mount2 as mount
8
+ };
@@ -0,0 +1,13 @@
1
+ import type { Middleware, Next, PipelineOptions } from "./types";
2
+ export declare abstract class Pipeline<C> {
3
+ #private;
4
+ readonly options: PipelineOptions;
5
+ parent?: Pipeline<any>;
6
+ protected readonly transparent: boolean;
7
+ constructor(options?: PipelineOptions);
8
+ get sources(): readonly object[];
9
+ use(middleware: Middleware<C>): this;
10
+ abstract callback(): (...input: any[]) => Promise<unknown>;
11
+ protected adopt(source: object | undefined): void;
12
+ protected chain(): (ctx: C, next?: Next) => Promise<void>;
13
+ }
@@ -0,0 +1,9 @@
1
+ // @bun
2
+ import {
3
+ Pipeline2
4
+ } from "./rhythm-vpajpf9k.js";
5
+ import"./rhythm-5w3dm83b.js";
6
+ import"./rhythm-7z989406.js";
7
+ export {
8
+ Pipeline2 as Pipeline
9
+ };
@@ -0,0 +1,18 @@
1
+ // @bun
2
+ // src/derive.ts
3
+ function derive2(factory) {
4
+ const middleware = (ctx, next) => {
5
+ const values = factory(ctx);
6
+ if (values instanceof Promise) {
7
+ return values.then((resolved) => {
8
+ Object.assign(ctx, resolved);
9
+ return next();
10
+ });
11
+ }
12
+ Object.assign(ctx, values);
13
+ return next();
14
+ };
15
+ return middleware;
16
+ }
17
+
18
+ export { derive2 };
@@ -0,0 +1,20 @@
1
+ // @bun
2
+ // src/compose.ts
3
+ function compose2(middleware) {
4
+ return (ctx, next) => {
5
+ let index = -1;
6
+ async function dispatch(i) {
7
+ if (i <= index) {
8
+ throw new Error("next() called multiple times");
9
+ }
10
+ index = i;
11
+ const fn = i === middleware.length ? next : middleware[i];
12
+ if (!fn)
13
+ return;
14
+ await fn(ctx, () => dispatch(i + 1));
15
+ }
16
+ return dispatch(0);
17
+ };
18
+ }
19
+
20
+ export { compose2 };
@@ -0,0 +1,25 @@
1
+ // @bun
2
+ import {
3
+ withSource2
4
+ } from "./rhythm-7z989406.js";
5
+ import {
6
+ wrapFailure
7
+ } from "./rhythm-vd616d70.js";
8
+
9
+ // src/mount.ts
10
+ function mount2(plugin, condition) {
11
+ const handler = plugin.callback();
12
+ const middleware = async (ctx, next) => {
13
+ if (!condition || condition(ctx)) {
14
+ try {
15
+ await handler(ctx);
16
+ } catch (cause) {
17
+ throw wrapFailure("mounted", plugin, cause);
18
+ }
19
+ }
20
+ await next();
21
+ };
22
+ return withSource2(middleware, plugin);
23
+ }
24
+
25
+ export { mount2 };
@@ -0,0 +1,25 @@
1
+ // @bun
2
+ import {
3
+ withSource2
4
+ } from "./rhythm-7z989406.js";
5
+ import {
6
+ wrapFailure
7
+ } from "./rhythm-vd616d70.js";
8
+
9
+ // src/include.ts
10
+ function include2(plugin, select) {
11
+ const callback = (ctx, app) => {
12
+ app.register(() => {}, () => plugin.stop());
13
+ const done = plugin.callback()(ctx).catch((cause) => {
14
+ throw wrapFailure("included", plugin, cause);
15
+ });
16
+ if (!select)
17
+ return done;
18
+ return done.then((child) => {
19
+ Object.assign(ctx, select(child));
20
+ });
21
+ };
22
+ return withSource2(callback, plugin);
23
+ }
24
+
25
+ export { include2 };
@@ -0,0 +1,16 @@
1
+ // @bun
2
+ // src/decorate.ts
3
+ function decorate2(factory) {
4
+ const callback = (ctx) => {
5
+ const values = factory(ctx);
6
+ if (values instanceof Promise) {
7
+ return values.then((resolved) => {
8
+ Object.assign(ctx, resolved);
9
+ });
10
+ }
11
+ Object.assign(ctx, values);
12
+ };
13
+ return callback;
14
+ }
15
+
16
+ export { decorate2 };
@@ -0,0 +1,8 @@
1
+ // @bun
2
+ // src/failure.ts
3
+ function wrapFailure(verb, plugin, cause) {
4
+ const { type = "module", name = "anonymous" } = plugin.options ?? {};
5
+ return new Error(`${verb} ${type} "${name}" failed`, { cause });
6
+ }
7
+
8
+ export { wrapFailure };
@@ -0,0 +1,39 @@
1
+ // @bun
2
+ import {
3
+ compose2
4
+ } from "./rhythm-5w3dm83b.js";
5
+ import {
6
+ sourceOf2
7
+ } from "./rhythm-7z989406.js";
8
+
9
+ // src/pipeline.ts
10
+ class Pipeline2 {
11
+ #middleware = [];
12
+ #sources = [];
13
+ options;
14
+ parent;
15
+ transparent = false;
16
+ constructor(options = {}) {
17
+ this.options = options;
18
+ }
19
+ get sources() {
20
+ return this.#sources.flatMap((source) => source instanceof Pipeline2 && source.transparent ? source.sources : [source]);
21
+ }
22
+ use(middleware) {
23
+ this.#middleware.push(middleware);
24
+ this.adopt(sourceOf2(middleware));
25
+ return this;
26
+ }
27
+ adopt(source) {
28
+ if (!source)
29
+ return;
30
+ if (source instanceof Pipeline2)
31
+ source.parent = this;
32
+ this.#sources.push(source);
33
+ }
34
+ chain() {
35
+ return compose2([...this.#middleware]);
36
+ }
37
+ }
38
+
39
+ export { Pipeline2 };
package/dist/rhythm.d.ts CHANGED
@@ -1,20 +1,23 @@
1
- import type { Condition, DeriveMiddleware, Middleware, OmitHashKeys } from "./types";
2
- export interface RhythmOptions {
3
- name?: string;
4
- type?: string;
5
- [key: string]: unknown;
6
- }
7
- export declare function derive<TContext extends object, TExtra extends object>(fn: (ctx: TContext) => TExtra | Promise<TExtra>): DeriveMiddleware<TContext, OmitHashKeys<TExtra>>;
8
- export declare class Rhythm<TInput extends object = {}, TStartup extends object = {}, TContext extends object = TInput & TStartup> {
1
+ import { Pipeline } from "./pipeline";
2
+ import type { CleanupCallback, ExtensionMiddleware, ExtensionRegister, Middleware, PipelineOptions, RegisterCallback, RhythmHandler } from "./types";
3
+ export { compose } from "./compose";
4
+ export { decorate } from "./decorate";
5
+ export { derive } from "./derive";
6
+ export { include } from "./include";
7
+ export { mount } from "./mount";
8
+ export { Pipeline } from "./pipeline";
9
+ export { sourceOf, withSource } from "./source";
10
+ export type * from "./types";
11
+ export declare class Rhythm<S extends object = {}, I extends object = {}, D extends object = {}> extends Pipeline<S & I & D> {
9
12
  #private;
10
- readonly context: TStartup;
11
- parent?: Rhythm<any, any, any>;
12
- constructor(options?: RhythmOptions);
13
- get sources(): readonly object[];
14
- use<TExtra extends object>(fn: DeriveMiddleware<TContext, TExtra>): Rhythm<TInput, TStartup, TContext & TExtra>;
15
- use(fn: Middleware<TContext>, condition?: Condition<TContext>): this;
16
- register<TRegInput extends object, TRegContext extends object, TExported extends object = {}>(other: Rhythm<TRegInput, any, TRegContext> & (TContext extends TRegInput ? unknown : never), exportValue?: (result: TRegContext) => TExported): Rhythm<TInput, TStartup, TContext & TExported>;
17
- callback(): (input: TInput) => Promise<TContext>;
18
- run(input: TInput): Promise<TContext>;
19
- middleware(): Middleware<TContext>;
13
+ readonly "~input"?: I;
14
+ protected readonly transparent = true;
15
+ constructor(options?: PipelineOptions);
16
+ register<U extends object>(callback: ExtensionRegister<S, U>, cleanup?: CleanupCallback<S & U>): Rhythm<S & U, I, D>;
17
+ register(callback: RegisterCallback<S>, cleanup?: CleanupCallback<S>): this;
18
+ stop(): Promise<void>;
19
+ [Symbol.asyncDispose](): Promise<void>;
20
+ use<U extends object>(middleware: ExtensionMiddleware<S & I & D, U>): Rhythm<S, I, D & U>;
21
+ use(middleware: Middleware<S & I & D>): this;
22
+ callback(): RhythmHandler<I, S & I & D>;
20
23
  }
package/dist/rhythm.js CHANGED
@@ -1,102 +1,86 @@
1
1
  // @bun
2
2
  import {
3
- gate2,
4
3
  compose2
5
- } from "./rhythm-4by5yd5a.js";
4
+ } from "./rhythm-5w3dm83b.js";
6
5
  import {
7
6
  withSource2,
8
7
  sourceOf2
9
8
  } from "./rhythm-7z989406.js";
9
+ import {
10
+ Pipeline2
11
+ } from "./rhythm-vpajpf9k.js";
12
+ import {
13
+ decorate2
14
+ } from "./rhythm-k0635rcp.js";
15
+ import {
16
+ derive2
17
+ } from "./rhythm-08xqt5pf.js";
18
+ import {
19
+ include2
20
+ } from "./rhythm-bq68je0h.js";
21
+ import {
22
+ mount2
23
+ } from "./rhythm-bhjtktgq.js";
10
24
 
11
25
  // src/rhythm.ts
12
- function publicEntries(value) {
13
- const exported = {};
14
- for (const [key, val] of Object.entries(value)) {
15
- if (!key.startsWith("#"))
16
- exported[key] = val;
17
- }
18
- return exported;
19
- }
20
- function derive(fn) {
21
- if (typeof fn !== "function")
22
- throw new TypeError("derive factory must be a function!");
23
- const middleware = async (ctx, next) => {
24
- const value = await fn(ctx);
25
- Object.assign(ctx, publicEntries(value));
26
- await next();
27
- };
28
- return middleware;
29
- }
30
-
31
- class Rhythm {
32
- #middleware = [];
33
- #options;
34
- #sources = [];
35
- context = {};
36
- parent;
37
- constructor(options = {}) {
38
- this.#options = options;
39
- }
40
- get sources() {
41
- return this.#sources.flatMap((source) => source instanceof Rhythm ? source.sources : [source]);
42
- }
43
- #adopt(source) {
44
- if (!source)
45
- return;
46
- if (source instanceof Rhythm)
47
- source.parent = this;
48
- this.#sources.push(source);
26
+ class Rhythm extends Pipeline2 {
27
+ #ctx = {};
28
+ #cleanups = [];
29
+ #ready;
30
+ transparent = true;
31
+ constructor(options) {
32
+ super({ type: "module", ...options });
49
33
  }
50
- use(fn, condition) {
51
- if (typeof fn !== "function")
52
- throw new TypeError("middleware must be a function!");
53
- this.#middleware.push(condition ? gate2(fn, condition) : fn);
54
- this.#adopt(sourceOf2(fn));
34
+ register(callback, cleanup) {
35
+ this.adopt(sourceOf2(callback));
36
+ const result = callback(this.#ctx, this);
37
+ if (result instanceof Promise) {
38
+ this.#ready = Promise.all([this.#ready, result]);
39
+ }
40
+ if (cleanup) {
41
+ this.#cleanups.push(() => cleanup(this.#ctx, this));
42
+ }
55
43
  return this;
56
44
  }
57
- register(other, exportValue) {
58
- const module = other;
59
- this.#adopt(module);
60
- this.#middleware.push(async (ctx, next) => {
61
- const inner = Object.assign({ ...ctx }, module.context);
62
- let downstream;
45
+ async stop() {
46
+ const errors = [];
47
+ while (this.#cleanups.length) {
48
+ const cleanup = this.#cleanups.pop();
63
49
  try {
64
- await compose2([...module.#middleware])(inner, async () => {
65
- try {
66
- if (exportValue)
67
- Object.assign(ctx, exportValue(inner));
68
- await next();
69
- } catch (error) {
70
- downstream = { error };
71
- throw error;
72
- }
73
- return inner;
74
- });
75
- } catch (cause) {
76
- if (downstream && downstream.error === cause)
77
- throw cause;
78
- const { type = "module", name = "anonymous" } = module.#options;
79
- throw new Error(`registered ${type} "${name}" failed`, { cause });
50
+ await cleanup();
51
+ } catch (error) {
52
+ errors.push(error);
80
53
  }
81
- });
82
- return this;
54
+ }
55
+ if (errors.length) {
56
+ throw new AggregateError(errors, "cleanup failed");
57
+ }
83
58
  }
84
- callback() {
85
- const fn = compose2([...this.#middleware]);
86
- return (input) => fn({ ...this.context, ...input });
59
+ [Symbol.asyncDispose]() {
60
+ return this.stop();
87
61
  }
88
- run(input) {
89
- return this.callback()(input);
62
+ use(middleware) {
63
+ return super.use(middleware);
90
64
  }
91
- middleware() {
92
- const fn = compose2([...this.#middleware]);
93
- return withSource2(async (ctx, next) => {
94
- Object.assign(ctx, this.context);
95
- await fn(ctx, next);
96
- }, this);
65
+ callback() {
66
+ const fn = this.chain();
67
+ return async (input) => {
68
+ if (this.#ready)
69
+ await this.#ready;
70
+ const ctx = Object.assign({}, this.#ctx, input);
71
+ await fn(ctx);
72
+ return ctx;
73
+ };
97
74
  }
98
75
  }
99
76
  export {
77
+ Pipeline2 as Pipeline,
100
78
  Rhythm,
101
- derive
79
+ compose2 as compose,
80
+ decorate2 as decorate,
81
+ derive2 as derive,
82
+ include2 as include,
83
+ mount2 as mount,
84
+ sourceOf2 as sourceOf,
85
+ withSource2 as withSource
102
86
  };
package/dist/source.d.ts CHANGED
@@ -1,3 +1,2 @@
1
- import type { Middleware } from "./types";
2
- export declare function withSource<TFn extends Middleware<any>>(fn: TFn, source: object): TFn;
1
+ export declare function withSource<F extends (...args: any[]) => unknown>(fn: F, source: object): F;
3
2
  export declare function sourceOf(fn: unknown): object | undefined;
package/dist/types.d.ts CHANGED
@@ -1,9 +1,23 @@
1
- export type OmitHashKeys<T> = {
2
- [K in keyof T as K extends `#${string}` ? never : K]: T[K];
1
+ import type { Rhythm } from "./rhythm";
2
+ export type Next = () => Promise<void>;
3
+ export type Middleware<T> = (ctx: T, next: Next) => unknown | Promise<unknown>;
4
+ declare const extension: unique symbol;
5
+ export type Extension<U extends object> = {
6
+ [extension]: U;
3
7
  };
4
- export type Condition<TContext extends object> = (ctx: TContext) => boolean | Promise<boolean>;
5
- export type NextFn<TContext extends object> = () => Promise<TContext>;
6
- export type Middleware<TContext extends object> = (ctx: TContext, next: NextFn<TContext>) => Promise<void> | void;
7
- export type DeriveMiddleware<TContext extends object, TExtra extends object> = Middleware<TContext> & {
8
- readonly "~derive": TExtra;
8
+ export type ExtensionMiddleware<T extends object, U extends object> = Middleware<T> & Extension<U>;
9
+ export type RegisterCallback<T extends object> = (ctx: T, app: Rhythm<T, any, any>) => unknown;
10
+ export type CleanupCallback<T extends object> = (ctx: T, app: Rhythm<T, any, any>) => unknown;
11
+ export type ExtensionRegister<T extends object, U extends object> = RegisterCallback<T> & Extension<U>;
12
+ export interface PipelineOptions {
13
+ name?: string;
14
+ type?: string;
15
+ }
16
+ export type Mountable<I extends object = any> = {
17
+ callback(): (...input: any[]) => Promise<unknown>;
18
+ readonly options?: PipelineOptions;
19
+ readonly "~input"?: I;
9
20
  };
21
+ export type RhythmInputArgs<I extends object> = {} extends I ? [input?: I] : [input: I];
22
+ export type RhythmHandler<I extends object, C extends object> = (...input: RhythmInputArgs<I>) => Promise<C>;
23
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rhythmjs/rhythm",
3
- "version": "0.0.16",
4
- "description": "A minimal, type-safe onion-middleware and provider composition kernel.",
3
+ "version": "0.0.17",
4
+ "description": "A minimal, type-safe middleware and startup-composition kernel: register() at startup, use() per request, with derive, decorate, mount and include.",
5
5
  "homepage": "https://rhythm.js.org/rhythm",
6
6
  "license": "ISC",
7
7
  "repository": {
@@ -23,6 +23,26 @@
23
23
  "types": "./dist/compose.d.ts",
24
24
  "default": "./dist/compose.js"
25
25
  },
26
+ "./pipeline": {
27
+ "types": "./dist/pipeline.d.ts",
28
+ "default": "./dist/pipeline.js"
29
+ },
30
+ "./derive": {
31
+ "types": "./dist/derive.d.ts",
32
+ "default": "./dist/derive.js"
33
+ },
34
+ "./decorate": {
35
+ "types": "./dist/decorate.d.ts",
36
+ "default": "./dist/decorate.js"
37
+ },
38
+ "./mount": {
39
+ "types": "./dist/mount.d.ts",
40
+ "default": "./dist/mount.js"
41
+ },
42
+ "./include": {
43
+ "types": "./dist/include.d.ts",
44
+ "default": "./dist/include.js"
45
+ },
26
46
  "./source": {
27
47
  "types": "./dist/source.d.ts",
28
48
  "default": "./dist/source.js"
@@ -45,7 +65,7 @@
45
65
  "bun": ">=1.2.0"
46
66
  },
47
67
  "scripts": {
48
- "build": "bun build src/rhythm.ts src/compose.ts src/source.ts src/types.ts --outdir dist --root src --format esm --target bun --packages external --splitting && tsc -p tsconfig.build.json",
68
+ "build": "rm -rf dist && bun build src/rhythm.ts src/compose.ts src/pipeline.ts src/derive.ts src/decorate.ts src/mount.ts src/include.ts src/source.ts src/types.ts --outdir dist --root src --format esm --target bun --packages external --splitting && tsc -p tsconfig.build.json",
49
69
  "typecheck": "tsc --noEmit",
50
70
  "test": "bun test"
51
71
  }
@@ -1,42 +0,0 @@
1
- // @bun
2
- // src/compose.ts
3
- var toVoid = () => {};
4
- function gate2(fn, condition) {
5
- if (typeof condition !== "function")
6
- throw new TypeError("condition must be a function!");
7
- return (ctx, next) => {
8
- const pass = condition(ctx);
9
- if (typeof pass === "boolean")
10
- return pass ? fn(ctx, next) : next().then(toVoid);
11
- return pass.then((ok) => ok ? fn(ctx, next) : next().then(toVoid));
12
- };
13
- }
14
- function compose2(middleware) {
15
- if (!Array.isArray(middleware))
16
- throw new TypeError("Middleware stack must be an array!");
17
- for (const fn of middleware) {
18
- if (typeof fn !== "function")
19
- throw new TypeError("Middleware must be composed of functions!");
20
- }
21
- return function(context, next) {
22
- let index = -1;
23
- return dispatch(0);
24
- function dispatch(i) {
25
- if (i <= index)
26
- return Promise.reject(new Error("next() called multiple times"));
27
- index = i;
28
- const fn = i === middleware.length ? next : middleware[i];
29
- if (!fn)
30
- return Promise.resolve(context);
31
- const dispatchNext = () => dispatch(i + 1);
32
- try {
33
- const call = fn;
34
- return Promise.resolve(call(context, dispatchNext)).then(() => context);
35
- } catch (err) {
36
- return Promise.reject(err);
37
- }
38
- }
39
- };
40
- }
41
-
42
- export { gate2, compose2 };
@@ -1,31 +0,0 @@
1
- // @bun
2
- // src/compose.ts
3
- function compose2(middleware) {
4
- if (!Array.isArray(middleware))
5
- throw new TypeError("Middleware stack must be an array!");
6
- for (const fn of middleware) {
7
- if (typeof fn !== "function")
8
- throw new TypeError("Middleware must be composed of functions!");
9
- }
10
- return function(context, next) {
11
- let index = -1;
12
- return dispatch(0);
13
- function dispatch(i) {
14
- if (i <= index)
15
- return Promise.reject(new Error("next() called multiple times"));
16
- index = i;
17
- const fn = i === middleware.length ? next : middleware[i];
18
- if (!fn)
19
- return Promise.resolve(context);
20
- const dispatchNext = () => dispatch(i + 1);
21
- try {
22
- const call = fn;
23
- return Promise.resolve(call(context, dispatchNext)).then(() => context);
24
- } catch (err) {
25
- return Promise.reject(err);
26
- }
27
- }
28
- };
29
- }
30
-
31
- export { compose2 };
@@ -1,42 +0,0 @@
1
- // @bun
2
- // src/compose.ts
3
- var toVoid = () => {};
4
- function gate2(fn, when) {
5
- if (typeof when !== "function")
6
- throw new TypeError("when must be a function!");
7
- return (ctx, next) => {
8
- const pass = when(ctx);
9
- if (typeof pass === "boolean")
10
- return pass ? fn(ctx, next) : next().then(toVoid);
11
- return pass.then((ok) => ok ? fn(ctx, next) : next().then(toVoid));
12
- };
13
- }
14
- function compose2(middleware) {
15
- if (!Array.isArray(middleware))
16
- throw new TypeError("Middleware stack must be an array!");
17
- for (const fn of middleware) {
18
- if (typeof fn !== "function")
19
- throw new TypeError("Middleware must be composed of functions!");
20
- }
21
- return function(context, next) {
22
- let index = -1;
23
- return dispatch(0);
24
- function dispatch(i) {
25
- if (i <= index)
26
- return Promise.reject(new Error("next() called multiple times"));
27
- index = i;
28
- const fn = i === middleware.length ? next : middleware[i];
29
- if (!fn)
30
- return Promise.resolve(context);
31
- const dispatchNext = () => dispatch(i + 1);
32
- try {
33
- const call = fn;
34
- return Promise.resolve(call(context, dispatchNext)).then(() => context);
35
- } catch (err) {
36
- return Promise.reject(err);
37
- }
38
- }
39
- };
40
- }
41
-
42
- export { gate2, compose2 };