Commit ada020c6 authored by nanahira's avatar nanahira

allow in-provider provide

parent 4c10330f
# nfkit # nfkit
Common kits `nfkit` is a TypeScript utility package focused on practical backend/frontend engineering helpers: middleware dispatching, i18n pipeline, lightweight app context (DI-like), task scheduling, abortable proxies, and more.
\ No newline at end of file
## Feature Count (public exports)
Based on `index.ts`, the package currently exposes **14 public entries**:
- **13 functional modules**
- **1 shared types module** (`types`)
| # | Entry | Purpose |
| --- | --- | --- |
| 1 | `workflow` | Lazy chained calls for property/method/function invocation |
| 2 | `dual-object` | Dual-state object (`sync object + PromiseLike`) |
| 3 | `workflow-dispatcher` | Multi-worker task scheduler with retry/backoff and dynamic worker ops |
| 4 | `round-robin` | Round-robin selector |
| 5 | `abortable` | AbortSignal-aware proxy wrapper |
| 6 | `middleware-dispatcher` | Generic middleware dispatching (dynamic/proto variants included) |
| 7 | `i18n` | i18n pipeline and dictionary lookup middleware |
| 8 | `patch-string-in-object` | Deep string patching inside object graphs |
| 9 | `observe-diff` | Object mutation diff observer (`add`/`update`/`delete`) |
| 10 | `memorize` | Per-instance memoization decorator for methods/getters |
| 11 | `may-be-array` | `T | T[]` type helper + normalization utility |
| 12 | `app-context` | Lightweight app context (`provide/use/start/get`) |
| 13 | `configurer` | Config loading/parsing helpers |
| 14 | `types` | Shared type exports (`Awaitable`, etc.) |
## Installation
```bash
npm i nfkit
```
## Quick Start
```ts
import {
createAppContext,
MiddlewareDispatcher,
I18n,
I18nLookupMiddleware,
WorkflowDispatcher,
} from 'nfkit';
```
## API and Usage
### 1) `workflow`
Builds a lazy chain. Execution starts only when you `await` (or call `.then/.catch/.finally`).
**API**
```ts
workflow<T>(source: T | Promise<T>): Chain<T>
```
**Example**
```ts
import { workflow } from 'nfkit';
class Client {
count = 0;
async connect() {
return this;
}
inc(n: number) {
this.count += n;
return this;
}
}
const client = new Client();
const value = await workflow(client).connect().inc(2).count;
// value === 2
```
### 2) `dual-object`
Creates a dual-state object:
- sync access path for properties/methods
- async object path via `await obj`
- pending-safe deferred calls for selected async methods (`asyncMethods`)
**API**
```ts
type Dual<T> = T & PromiseLike<T>;
const DUAL_PENDING: unique symbol;
throwDualPending(): never;
dualizeAny<T>(
sync: () => T,
asyncFn: () => Promise<T>,
options?: { asyncMethods?: readonly (keyof T)[] },
): Dual<T>
```
**Example**
```ts
import { dualizeAny } from 'nfkit';
type UserClient = {
id: string;
ping(): Promise<number>;
};
const c = dualizeAny<UserClient>(
() => ({ id: 'cached', ping: async () => 1 }),
async () => ({ id: 'remote', ping: async () => 42 }),
{ asyncMethods: ['ping'] as const },
);
const v = await c.ping(); // works even while object is pending
const obj = await c;
```
### 3) `workflow-dispatcher`
Asynchronous multi-worker dispatcher with:
- global dispatch (auto worker selection)
- worker-specific dispatch
- retry + exponential backoff
- runtime worker replace/add/remove
**API**
```ts
new WorkflowDispatcher<F extends (...args: any[]) => Promise<any>>(
workersOrPromises: Array<F | Promise<F>>,
options?: { maxAttempts?: number; backoffBaseMs?: number },
)
dispatch(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>>
dispatchSpecific(index: number, ...args: Parameters<F>): Promise<Awaited<ReturnType<F>>>
replaceWorker(index: number, fn: F): void
addWorker(fn: F): number
removeWorker(index: number): Promise<void>
snapshot(): WorkerSnapshot<F>[]
get pending(): number
```
**Example**
```ts
import { WorkflowDispatcher } from 'nfkit';
type Worker = (x: number) => Promise<string>;
const d = new WorkflowDispatcher<Worker>([
async (x) => `A:${x}`,
async (x) => `B:${x}`,
]);
const r1 = await d.dispatch(1);
const r2 = await d.dispatchSpecific(0, 2);
```
### 4) `round-robin`
Simple round-robin picker.
**API**
```ts
class RoundRobin<T> {
constructor(items: T[]);
next(): T;
}
```
**Example**
```ts
import { RoundRobin } from 'nfkit';
const rr = new RoundRobin(['a', 'b', 'c']);
rr.next(); // a
rr.next(); // b
rr.next(); // c
rr.next(); // a
```
### 5) `abortable`
Wraps an object/function with an AbortSignal-aware proxy. After abort, access/calls throw (or reject for Promise flows).
**API**
```ts
class AbortedError extends Error {}
type AbortableOpts = {
boxPrimitives?: boolean;
noRecursive?: boolean;
};
abortable<T>(obj: T, signal: AbortSignal, opts?: AbortableOpts): T
```
**Example**
```ts
import { abortable } from 'nfkit';
const ac = new AbortController();
const obj = abortable({ n: 1 }, ac.signal);
obj.n; // 1
ac.abort('stop');
// later access throws AbortedError
```
### 6) `middleware-dispatcher`
Generic middleware execution model: `(args..., next)`.
Includes 3 dispatchers:
- `DynamicMiddlewareDispatcher<F>`: override `buildMiddlewares`
- `MiddlewareDispatcher<F>`: direct array registration
- `ProtoMiddlewareDispatcher<A>`: middleware by instance prototype chain
**Core types/options**
```ts
type Middleware<F>
type MiddlewareNext<F>
type MiddlewareDispatcherOptions<F> = {
acceptResult?: (res) => boolean | Promise<boolean>;
errorHandler?: (e, args, next) => any;
};
```
**Example**
```ts
import { MiddlewareDispatcher } from 'nfkit';
type Handler = (x: number) => number;
const d = new MiddlewareDispatcher<Handler>();
d.middleware(async (x, next) => {
const r = await next();
return (r ?? 0) + 1;
});
d.middleware(async (x) => x * 10);
const out = await d.dispatch(2); // 21
```
### 7) `i18n`
Middleware-based translation pipeline with built-in dictionary lookup middleware.
**API**
```ts
class I18n<Ex extends any[] = []> {
constructor(options: { locales: string[]; defaultLocale?: string });
middleware(mw: I18nMiddleware<Ex>, prior?: boolean): this;
removeMiddleware(mw: I18nMiddleware<Ex>): this;
getExactLocale(locale: string): string;
translateString(locale: string, text: string, ...ex: Ex): Promise<string>;
translate<T>(locale: string, obj: T, ...ex: Ex): Promise<T>;
}
type I18nDictionary = Record<string, Record<string, string>>;
I18nLookupMiddleware(
dictOrFactory,
options?: { matchType?: 'exact' | 'hierarchy' | 'startsWith' },
)
createI18nLookupMiddleware<Ex>()
```
**Example**
```ts
import { I18n, I18nLookupMiddleware } from 'nfkit';
const i18n = new I18n({ locales: ['en', 'zh', 'zh-Hans'], defaultLocale: 'en' });
i18n.middleware(
I18nLookupMiddleware(
{
en: { hello: 'Hello' },
'zh-Hans': { hello: '你好' },
},
{ matchType: 'hierarchy' },
),
);
const s = await i18n.translateString('zh-Hans-CN', 'Say #{hello}');
// Say 你好
```
### 8) `patch-string-in-object`
Deeply traverses an object graph and transforms all string values (async callback supported).
**API**
```ts
patchStringInObject<T>(
obj: T,
cb: (s: string) => string | Promise<string>,
): Promise<T>
```
**Example**
```ts
import { patchStringInObject } from 'nfkit';
const out = await patchStringInObject(
{ a: 'hello', nested: ['x', 'y'] },
(s) => s.toUpperCase(),
);
// { a: 'HELLO', nested: ['X', 'Y'] }
```
### 9) `observe-diff`
Creates a proxy that reports `add/update/delete` changes on property writes/deletes.
**API**
```ts
observeDiff<T>(
obj: T,
cb: (change: {
type: 'add' | 'update' | 'delete';
key: keyof T;
oldValue: T[keyof T] | undefined;
newValue: T[keyof T] | undefined;
}) => any,
): T
```
**Example**
```ts
import { observeDiff } from 'nfkit';
const state = observeDiff<{ count?: number }>({}, (c) => {
console.log(c.type, c.key, c.oldValue, c.newValue);
});
state.count = 1; // add
state.count = 2; // update
delete state.count; // delete
```
### 10) `memorize`
Per-instance memoization decorator for methods/getters.
**API**
```ts
Memorize(): MethodDecorator & PropertyDecorator
```
**Example**
```ts
import { Memorize } from 'nfkit';
class Svc {
calls = 0;
@Memorize()
expensive() {
this.calls += 1;
return Math.random();
}
}
```
> Requires TypeScript decorator support (for example `experimentalDecorators`).
### 11) `may-be-array`
Utility for handling `single value or array` input consistently.
**API**
```ts
type MayBeArray<T> = T | T[];
makeArray<T>(value: MayBeArray<T>): T[];
```
**Example**
```ts
import { makeArray } from 'nfkit';
makeArray(1); // [1]
makeArray([1, 2]); // [1, 2]
```
### 12) `app-context`
Lightweight app context / DI-like container.
Supports:
- provider registration via `provide`
- context composition via `use`
- lifecycle initialization via `start`
- service lookup via `get/getAsync`
**Core API**
```ts
createAppContext<Req = Empty>(): AppContext<Empty, Req>
ctx.provide(ServiceClass, ...args?, options?)
ctx.use(otherCtx)
ctx.get(ServiceClass)
ctx.getAsync(ServiceClass)
ctx.define()
ctx.start()
```
**Type-driven IoC contract**
`provide()` is type constrained by `AppServiceClass`, whose constructor signature is:
```ts
new (ctx: AppContext<...>, ...args) => Service
```
That means the **first constructor parameter must be `ctx`**.
The type system also tracks context shape changes:
- `provide: 'name'` adds `ctx.name` as that service instance type.
- `merge: ['memberA', 'memberB']` maps selected members from service onto `ctx` with correct member types.
- `use(otherCtx)` merges provided/required context types across contexts.
- `start()` resolves to `never` at type level if required context (`Req`) is not satisfied.
Common `provide` options:
```ts
{
provide?: string; // expose service instance as ctx[prop]
merge?: string[]; // map selected service members onto ctx
useValue?: instance | Promise<instance>;
useFactory?: (ctx, ...args) => instance | Promise<instance>;
useClass?: Class;
}
```
`provide` vs `merge`:
- `provide`: exposes the whole service instance on context (for example `ctx.logger`).
- `merge`: exposes selected service members directly on context (for example `ctx.info` from `ctx.logger.info`).
Current runtime semantics:
- `ctx.provide/ctx.use` inside a provider constructor: newly added providers are settled after that constructor ends.
- `ctx.provide/ctx.use` inside `init()`: newly added providers are settled and initialized immediately after the current `init` ends.
- calling `provide/use` after `start()`: providers are queued; call `define()` to trigger settlement.
- `use(startedCtx)`: merges existing instances directly; no re-construction and no re-init.
**Example**
```ts
import { AppContext, createAppContext } from 'nfkit';
class LoggerService {
constructor(
public ctx: AppContext,
private prefix = '[app]',
) {}
logs: string[] = [];
info(s: string) {
this.logs.push(`${this.prefix} ${s}`);
}
}
const app = await createAppContext()
// first constructor argument of provider class is always ctx
.provide(LoggerService, '[core]', {
provide: 'logger', // ctx.logger -> LoggerService
merge: ['info'], // ctx.info -> bound logger.info
})
.define()
.start();
app.logger.info('from instance');
app.info('from merged method');
```
### 13) `configurer`
Config helper for string-based configuration (env-style), with typed getters.
**API**
```ts
class Configurer<T extends Record<string, string>> {
constructor(defaultConfig: T);
loadConfig(options?: {
env?: Record<string, string | undefined>;
obj?: any;
}): ConfigurerInstance<T>;
generateExampleObject(): Record<string, unknown>;
}
class ConfigurerInstance<T extends Record<string, string>> {
getString(key): string;
getInt(key): number;
getFloat(key): number;
getBoolean(key): boolean;
getStringArray(key): string[];
getIntArray(key): number[];
getFloatArray(key): number[];
getBooleanArray(key): boolean[];
getJSON<R>(key): R;
}
```
Merge priority:
`defaultConfig < obj < env`
**Example**
```ts
import { Configurer } from 'nfkit';
const cfg = new Configurer({ PORT: '3000', ENABLE_CACHE: '1' }).loadConfig({
env: { PORT: '8080' },
});
cfg.getInt('PORT'); // 8080
cfg.getBoolean('ENABLE_CACHE'); // true
```
### 14) `types`
Shared type exports:
```ts
type Awaitable<T> = T | Promise<T>;
type AnyClass = new (...args: any[]) => any;
type ClassType<T = any> = new (...args: any[]) => T;
interface Empty {}
type Prettify<T> = ...
```
## Notes
This README reflects the current implementation exported by `index.ts`.
If exports change in future versions, treat source/types as the final reference.
...@@ -33,6 +33,9 @@ type LoadEntry = { ...@@ -33,6 +33,9 @@ type LoadEntry = {
type ObjectStep = (ctx: AppContextCore<any, any>) => void; type ObjectStep = (ctx: AppContextCore<any, any>) => void;
const isPromiseLike = (value: any): value is Promise<any> =>
!!value && typeof value.then === 'function';
export class AppContextCore<Cur = Empty, Req = Empty> { export class AppContextCore<Cur = Empty, Req = Empty> {
private __current: Cur; private __current: Cur;
private __required: Req; private __required: Req;
...@@ -42,18 +45,90 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -42,18 +45,90 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
private objectSteps: ObjectStep[] = []; private objectSteps: ObjectStep[] = [];
started = false; started = false;
private starting = false; private starting = false;
private startingEntries: LoadEntry[] | null = null; private settleCursor = 0;
private createdRecords: Set<ProvideRecord> | null = null; private recordEntries = new Map<ProvideRecord, LoadEntry>();
private createdEntries: Map<ProvideRecord, LoadEntry> | null = null; private pendingInitQueue: LoadEntry[] = [];
private pendingInitSet = new Set<LoadEntry>();
private initializedEntries = new Set<LoadEntry>();
private loadingRecords = new Set<ProvideRecord>(); private loadingRecords = new Set<ProvideRecord>();
private settlementChain: Promise<void> = Promise.resolve();
private settlementError: unknown = null;
private settling = false;
private assertSettlementHealthy() {
if (this.settlementError) {
throw this.settlementError;
}
}
private enqueueInit(entry: LoadEntry) {
if (this.initializedEntries.has(entry) || this.pendingInitSet.has(entry)) {
return;
}
this.pendingInitSet.add(entry);
this.pendingInitQueue.push(entry);
}
private async constructPendingRecords() {
while (this.settleCursor < this.provideRecords.length) {
const record = this.provideRecords[this.settleCursor];
this.settleCursor += 1;
await this.createEntryFromRecord(record);
}
}
private async settlePending() {
await this.constructPendingRecords();
while (this.pendingInitQueue.length > 0) {
const entry = this.pendingInitQueue.shift()!;
this.pendingInitSet.delete(entry);
if (this.initializedEntries.has(entry)) {
continue;
}
const inst = await entry.inst;
entry.inst = inst;
if (inst && typeof inst.init === 'function') {
await inst.init();
}
this.initializedEntries.add(entry);
// Providers added during this init must settle and init immediately
// after current init, before previous remaining init queue items.
const remaining = this.pendingInitQueue.splice(0);
await this.constructPendingRecords();
this.pendingInitQueue.push(...remaining);
}
}
private scheduleSettlement() {
const run = async () => {
this.assertSettlementHealthy();
this.settling = true;
try {
await this.settlePending();
} catch (error) {
this.settlementError = error;
throw error;
} finally {
this.settling = false;
}
};
const task = this.settlementChain.then(run, run);
this.settlementChain = task.catch(() => undefined);
return task;
}
private triggerSettlement() {
void this.scheduleSettlement();
}
private findProvideRecord(key: AnyClass): ProvideRecord | undefined { private findProvideRecord(key: AnyClass): ProvideRecord | undefined {
for (let i = 0; i < this.provideRecords.length; i += 1) { for (let i = 0; i < this.provideRecords.length; i += 1) {
const record = this.provideRecords[i]; const record = this.provideRecords[i];
if ( if (record.classRef === key && !this.recordEntries.has(record)) {
record.classRef === key &&
(!this.createdRecords || !this.createdRecords.has(record))
) {
return record; return record;
} }
} }
...@@ -61,7 +136,7 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -61,7 +136,7 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
} }
private createEntryFromRecord(record: ProvideRecord): Awaitable<LoadEntry> { private createEntryFromRecord(record: ProvideRecord): Awaitable<LoadEntry> {
const existing = this.createdEntries?.get(record); const existing = this.recordEntries.get(record);
if (existing) { if (existing) {
return existing; return existing;
} }
...@@ -78,11 +153,10 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -78,11 +153,10 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
classRef: record.classRef, classRef: record.classRef,
inst: record.factory(this), inst: record.factory(this),
}; };
this.createdRecords?.add(record); this.recordEntries.set(record, entry);
this.createdEntries?.set(record, entry);
this.registry.set(record.classRef, entry); this.registry.set(record.classRef, entry);
this.startingEntries?.push(entry); this.enqueueInit(entry);
if (entry.inst?.then && typeof entry.inst.then === 'function') { if (isPromiseLike(entry.inst)) {
return entry.inst.then((resolvedInst: any) => { return entry.inst.then((resolvedInst: any) => {
entry.inst = resolvedInst; entry.inst = resolvedInst;
return entry; return entry;
...@@ -102,6 +176,8 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -102,6 +176,8 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
cls: C, cls: C,
...args: AppProvideArgs<Cur, Req, C, P, M> ...args: AppProvideArgs<Cur, Req, C, P, M>
): AppProvidedMerged<Cur, Req, C, P, M> { ): AppProvidedMerged<Cur, Req, C, P, M> {
this.assertSettlementHealthy();
const last = args[args.length - 1] as any; const last = args[args.length - 1] as any;
const hasOptions = const hasOptions =
!!last && !!last &&
...@@ -199,6 +275,8 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -199,6 +275,8 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
| AppServiceClass<Cur, Req, any, R> | AppServiceClass<Cur, Req, any, R>
| (() => AppServiceClass<Cur, Req, any, R>), | (() => AppServiceClass<Cur, Req, any, R>),
): R { ): R {
this.assertSettlementHealthy();
let key = cls as unknown as AnyClass; let key = cls as unknown as AnyClass;
if ( if (
!this.registry.has(key) && !this.registry.has(key) &&
...@@ -208,7 +286,7 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -208,7 +286,7 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
key = (cls as () => AppServiceClass<Cur, Req, any, R>)() as AnyClass; key = (cls as () => AppServiceClass<Cur, Req, any, R>)() as AnyClass;
} }
if (!this.registry.has(key) && this.starting) { if (!this.registry.has(key) && (this.starting || this.settling)) {
const record = this.findProvideRecord(key); const record = this.findProvideRecord(key);
if (record) { if (record) {
this.createEntryFromRecord(record); this.createEntryFromRecord(record);
...@@ -234,17 +312,14 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -234,17 +312,14 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
use<const Ctxes extends AppContext<any, any>[]>( use<const Ctxes extends AppContext<any, any>[]>(
...ctxes: Ctxes ...ctxes: Ctxes
): AppContextUsed<Cur, Req, Ctxes> { ): AppContextUsed<Cur, Req, Ctxes> {
this.assertSettlementHealthy();
for (const ctx of ctxes) { for (const ctx of ctxes) {
const other = ctx as any as AppContextCore<any, any>; const other = ctx as any as AppContextCore<any, any>;
if (this.started && !other?.started) { // Started contexts are merged by instance directly (registry copy),
throw new Error( // and should not queue reconstruction/re-init on target context.
'Cannot use an unstarted context into a started context.', if (Array.isArray(other?.provideRecords) && !other?.started) {
);
}
// Copy provide records
if (Array.isArray(other?.provideRecords)) {
this.provideRecords.push(...other.provideRecords); this.provideRecords.push(...other.provideRecords);
} }
...@@ -271,6 +346,10 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -271,6 +346,10 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
} }
define(): AppContext<Cur, Req> { define(): AppContext<Cur, Req> {
this.assertSettlementHealthy();
if (this.started && !this.starting) {
this.triggerSettlement();
}
return this as any; return this as any;
} }
...@@ -279,35 +358,13 @@ export class AppContextCore<Cur = Empty, Req = Empty> { ...@@ -279,35 +358,13 @@ export class AppContextCore<Cur = Empty, Req = Empty> {
return this as any; return this as any;
} }
const startedEntries: LoadEntry[] = []; this.assertSettlementHealthy();
const preloadedKeys = new Set(this.registry.keys());
this.starting = true; this.starting = true;
this.startingEntries = startedEntries;
this.createdRecords = new Set();
this.createdEntries = new Map();
try { try {
// Create all instances. Dependencies requested via get() during construction await this.scheduleSettlement();
// can be created on-demand from remaining provide records.
for (const record of this.provideRecords) {
if (preloadedKeys.has(record.classRef)) {
continue;
}
await this.createEntryFromRecord(record);
}
// Init only instances created in this start().
for (const entry of startedEntries) {
const inst = entry.inst;
if (inst && typeof inst.init === 'function') {
await inst.init();
}
}
this.started = true; this.started = true;
return this as any; return this as any;
} finally { } finally {
this.createdEntries = null;
this.createdRecords = null;
this.startingEntries = null;
this.starting = false; this.starting = false;
} }
} }
......
import { createAppContext, AppContext } from '../src/app-context';
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const waitFor = async (fn: () => boolean, timeoutMs = 500) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (fn()) {
return;
}
await delay(2);
}
throw new Error('waitFor timeout');
};
describe('app-context dynamic settle behavior', () => {
test('constructor phase supports two-level provide/use chain and settles after current constructor', async () => {
const logs: string[] = [];
class CService {
constructor(public ctx: AppContext) {
logs.push('construct:C');
}
}
class UsedInBService {
constructor(public ctx: AppContext) {
logs.push('construct:UB');
}
}
class BService {
constructor(
public ctx: AppContext,
private usedCtx: AppContext,
) {
logs.push('construct:B:start');
ctx.provide(CService, { provide: 'cService' });
ctx.use(usedCtx);
logs.push('construct:B:end');
}
}
class AService {
constructor(
public ctx: AppContext,
private usedCtx: AppContext,
) {
logs.push('construct:A:start');
ctx.provide(BService, usedCtx, { provide: 'bService' });
logs.push('construct:A:end');
}
}
const usedCtx = createAppContext()
.provide(UsedInBService, { provide: 'uB' })
.define();
const app = await createAppContext()
.provide(AService, usedCtx, { provide: 'aService' })
.define()
.start();
expect(logs).toEqual([
'construct:A:start',
'construct:A:end',
'construct:B:start',
'construct:B:end',
'construct:C',
'construct:UB',
]);
expect(app.aService).toBeInstanceOf(AService);
expect(app.get(BService)).toBeInstanceOf(BService);
expect(app.get(CService)).toBeInstanceOf(CService);
expect(app.get(UsedInBService)).toBeInstanceOf(UsedInBService);
});
test('constructor phase allows inverted dependency for providers added in same constructor', async () => {
const logs: string[] = [];
class LateCtorDepService {
constructor(public ctx: AppContext) {
logs.push('construct:late-ctor');
}
}
class EarlyCtorDepService {
dep: LateCtorDepService;
constructor(public ctx: AppContext) {
logs.push('construct:early-ctor:start');
this.dep = ctx.get(LateCtorDepService);
logs.push('construct:early-ctor:end');
}
}
class HostCtorProvideService {
constructor(public ctx: AppContext) {
logs.push('construct:host-ctor:start');
ctx.provide(EarlyCtorDepService, { provide: 'earlyCtorDep' });
ctx.provide(LateCtorDepService, { provide: 'lateCtorDep' });
logs.push('construct:host-ctor:end');
}
}
const app = await createAppContext()
.provide(HostCtorProvideService, { provide: 'hostCtorProvide' })
.define()
.start();
const early = app.get(EarlyCtorDepService);
const late = app.get(LateCtorDepService);
expect(early.dep).toBe(late);
expect(logs).toEqual([
'construct:host-ctor:start',
'construct:host-ctor:end',
'construct:early-ctor:start',
'construct:late-ctor',
'construct:early-ctor:end',
]);
});
test('init phase supports two-level provide/use chain and settles immediately after each init', async () => {
const logs: string[] = [];
class UsedInAInitService {
constructor(public ctx: AppContext) {
logs.push('construct:U1');
}
init() {
logs.push('init:U1');
}
}
class UsedInBInitService {
constructor(public ctx: AppContext) {
logs.push('construct:U2');
}
init() {
logs.push('init:U2');
}
}
class CFromInitService {
constructor(public ctx: AppContext) {
logs.push('construct:C');
}
init() {
logs.push('init:C');
}
}
class BFromInitService {
constructor(
public ctx: AppContext,
private usedCtx: AppContext,
) {
logs.push('construct:B');
}
init() {
logs.push('init:B:start');
this.ctx.provide(CFromInitService, { provide: 'cFromInit' });
this.ctx.use(this.usedCtx);
logs.push('init:B:end');
}
}
class AFromInitService {
constructor(
public ctx: AppContext,
private usedCtxForB: AppContext,
private usedCtxForA: AppContext,
) {
logs.push('construct:A');
}
init() {
logs.push('init:A:start');
this.ctx.provide(BFromInitService, this.usedCtxForB, {
provide: 'bFromInit',
});
this.ctx.use(this.usedCtxForA);
logs.push('init:A:end');
}
}
class TailService {
constructor(public ctx: AppContext) {
logs.push('construct:Tail');
}
init() {
logs.push('init:Tail');
}
}
const usedCtxForA = createAppContext()
.provide(UsedInAInitService, { provide: 'u1' })
.define();
const usedCtxForB = createAppContext()
.provide(UsedInBInitService, { provide: 'u2' })
.define();
const app = await createAppContext()
.provide(AFromInitService, usedCtxForB, usedCtxForA, {
provide: 'aFromInit',
})
.provide(TailService, { provide: 'tail' })
.define()
.start();
expect(logs).toEqual([
'construct:A',
'construct:Tail',
'init:A:start',
'init:A:end',
'construct:B',
'construct:U1',
'init:B:start',
'init:B:end',
'construct:C',
'construct:U2',
'init:C',
'init:U2',
'init:U1',
'init:Tail',
]);
expect(app.aFromInit).toBeInstanceOf(AFromInitService);
expect(app.get(BFromInitService)).toBeInstanceOf(BFromInitService);
expect(app.get(CFromInitService)).toBeInstanceOf(CFromInitService);
expect(app.get(UsedInAInitService)).toBeInstanceOf(UsedInAInitService);
expect(app.get(UsedInBInitService)).toBeInstanceOf(UsedInBInitService);
expect(app.tail).toBeInstanceOf(TailService);
});
test('init phase allows inverted dependency for providers added in same init', async () => {
const logs: string[] = [];
class LateInitDepService {
constructor(public ctx: AppContext) {
logs.push('construct:late-init');
}
init() {
logs.push('init:late-init');
}
}
class EarlyInitDepService {
dep: LateInitDepService;
constructor(public ctx: AppContext) {
logs.push('construct:early-init:start');
this.dep = ctx.get(LateInitDepService);
logs.push('construct:early-init:end');
}
init() {
logs.push('init:early-init');
}
}
class HostInitProvideService {
constructor(public ctx: AppContext) {
logs.push('construct:host-init');
}
init() {
logs.push('init:host-init:start');
this.ctx.provide(EarlyInitDepService, { provide: 'earlyInitDep' });
this.ctx.provide(LateInitDepService, { provide: 'lateInitDep' });
logs.push('init:host-init:end');
}
}
const app = await createAppContext()
.provide(HostInitProvideService, { provide: 'hostInitProvide' })
.define()
.start();
const early = app.get(EarlyInitDepService);
const late = app.get(LateInitDepService);
expect(early.dep).toBe(late);
expect(logs).toEqual([
'construct:host-init',
'init:host-init:start',
'init:host-init:end',
'construct:early-init:start',
'construct:late-init',
'construct:early-init:end',
'init:late-init',
'init:early-init',
]);
});
test('provide/use after start settles when define() is called', async () => {
const logs: string[] = [];
class PostStartBService {
constructor(public ctx: AppContext) {
logs.push('construct:PostB');
}
init() {
logs.push('init:PostB');
}
}
class PostStartAService {
constructor(
public ctx: AppContext,
private output: string[],
) {
this.output.push('construct:PostA:start');
ctx.provide(PostStartBService, { provide: 'postB' });
this.output.push('construct:PostA:end');
}
init() {
logs.push('init:PostA');
}
}
class PostStartUseService {
constructor(public ctx: AppContext) {
logs.push('construct:PostUse');
}
init() {
logs.push('init:PostUse');
}
}
const app = await createAppContext().define().start();
const useCtx = createAppContext()
.provide(PostStartUseService, { provide: 'postUse' })
.define();
app.provide(PostStartAService, logs, { provide: 'postA' });
app.use(useCtx);
expect(logs).toEqual([]);
app.define();
await waitFor(
() =>
logs.includes('init:PostA') &&
logs.includes('init:PostB') &&
logs.includes('init:PostUse'),
);
expect(app.get(PostStartAService)).toBeInstanceOf(PostStartAService);
expect(app.get(PostStartBService)).toBeInstanceOf(PostStartBService);
expect(app.get(PostStartUseService)).toBeInstanceOf(PostStartUseService);
});
test('after start, provide allows inverted dependency after define() settlement', async () => {
const logs: string[] = [];
class LateAfterStartProvideService {
constructor(public ctx: AppContext) {
logs.push('construct:late-post-provide');
}
}
class EarlyAfterStartProvideService {
dep: LateAfterStartProvideService;
constructor(public ctx: AppContext) {
logs.push('construct:early-post-provide:start');
this.dep = ctx.get(LateAfterStartProvideService);
logs.push('construct:early-post-provide:end');
}
}
class HostAfterStartProvideService {
constructor(public ctx: AppContext) {
logs.push('construct:host-post-provide:start');
ctx.provide(EarlyAfterStartProvideService, {
provide: 'earlyAfterStartProvide',
});
ctx.provide(LateAfterStartProvideService, {
provide: 'lateAfterStartProvide',
});
logs.push('construct:host-post-provide:end');
}
}
const app = await createAppContext().define().start();
app.provide(HostAfterStartProvideService, { provide: 'hostAfterStartProvide' });
expect(logs).toEqual([]);
app.define();
await waitFor(() => logs.includes('construct:early-post-provide:end'));
const early = app.get(EarlyAfterStartProvideService);
const late = app.get(LateAfterStartProvideService);
expect(early.dep).toBe(late);
expect(logs).toEqual([
'construct:host-post-provide:start',
'construct:host-post-provide:end',
'construct:early-post-provide:start',
'construct:late-post-provide',
'construct:early-post-provide:end',
]);
});
test('after start, use allows inverted dependency after define() settlement', async () => {
const logs: string[] = [];
class LateInUsedContextService {
constructor(public ctx: AppContext) {
logs.push('construct:late-post-use');
}
}
class EarlyInUsedContextService {
dep: LateInUsedContextService;
constructor(public ctx: AppContext) {
logs.push('construct:early-post-use:start');
this.dep = ctx.get(LateInUsedContextService);
logs.push('construct:early-post-use:end');
}
}
class HostInUsedContextService {
constructor(public ctx: AppContext) {
logs.push('construct:host-post-use:start');
ctx.provide(EarlyInUsedContextService, { provide: 'earlyInUsedContext' });
ctx.provide(LateInUsedContextService, { provide: 'lateInUsedContext' });
logs.push('construct:host-post-use:end');
}
}
const imported = createAppContext()
.provide(HostInUsedContextService, { provide: 'hostInUsedContext' })
.define();
const app = await createAppContext().define().start();
app.use(imported);
expect(logs).toEqual([]);
app.define();
await waitFor(() => logs.includes('construct:early-post-use:end'));
const early = app.get(EarlyInUsedContextService);
const late = app.get(LateInUsedContextService);
expect(early.dep).toBe(late);
expect(logs).toEqual([
'construct:host-post-use:start',
'construct:host-post-use:end',
'construct:early-post-use:start',
'construct:late-post-use',
'construct:early-post-use:end',
]);
});
test('using an already started context merges existing instances directly', async () => {
class StartedSourceService {
static constructCount = 0;
static initCount = 0;
constructor(public ctx: AppContext) {
StartedSourceService.constructCount += 1;
}
init() {
StartedSourceService.initCount += 1;
}
}
const source = await createAppContext()
.provide(StartedSourceService, { provide: 'startedSource' })
.define()
.start();
const target = await createAppContext().define().start();
const sourceInst = source.get(StartedSourceService);
target.use(source);
expect(target.get(StartedSourceService)).toBe(sourceInst);
expect((target as any).startedSource).toBe(sourceInst);
expect(StartedSourceService.constructCount).toBe(1);
expect(StartedSourceService.initCount).toBe(1);
});
});
...@@ -164,15 +164,16 @@ describe('app-context runtime', () => { ...@@ -164,15 +164,16 @@ describe('app-context runtime', () => {
expect(logs.indexOf('init:A')).toBeLessThan(logs.indexOf('init:B')); expect(logs.indexOf('init:A')).toBeLessThan(logs.indexOf('init:B'));
}); });
test('started context cannot use unstarted context', async () => { test('started context can use unstarted context and settle on define()', async () => {
const started = await createAppContext().define().start(); const started = await createAppContext().define().start();
const unstarted = createAppContext() const unstarted = createAppContext()
.provide(CounterService, 1, { provide: 'counter' }) .provide(CounterService, 1, { provide: 'counter' })
.define(); .define();
expect(() => started.use(unstarted)).toThrow( started.use(unstarted).define();
'Cannot use an unstarted context into a started context.', await delay(0);
); expect(started.get(CounterService).value).toBe(1);
expect((started as any).counter.value).toBe(1);
}); });
test('using started context does not re-init imported providers', async () => { test('using started context does not re-init imported providers', async () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment