From 7695822cd849915ece9d5d88ac94ca2aed9d304e Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Sat, 15 Mar 2025 01:14:49 +0100 Subject: [PATCH 01/10] feat(framework): new option `logStartup` to control startup rpc/http endpoints logging --- packages/framework/src/application-server.ts | 11 ++++------- packages/framework/src/module.config.ts | 5 +++++ packages/framework/src/module.ts | 7 +++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/framework/src/application-server.ts b/packages/framework/src/application-server.ts index d654ebfd0..5e692f2fc 100644 --- a/packages/framework/src/application-server.ts +++ b/packages/framework/src/application-server.ts @@ -79,13 +79,13 @@ export const onServerWorkerShutdown = new EventToken('server.worker.shutdown', S type ApplicationServerConfig = Pick; + 'debug' | 'debugUrl' | 'gracefulShutdownTimeout' | 'compression' | 'http' | 'logStartup'>; function needsHttpWorker(config: { publicDir?: string }, rpcControllers: RpcControllers, router: HttpRouter) { return Boolean(config.publicDir || rpcControllers.controllers.size || router.getRoutes().length); } -export class ApplicationServerListener { +export class LogStartupListener { constructor( protected logger: LoggerInterface, protected rpcControllers: RpcControllers, @@ -117,8 +117,6 @@ export class ApplicationServerListener { } } - const httpActive = needsHttpWorker(this.config, this.rpcControllers, this.router); - if (this.config.server) { this.logger.log(`Server up and running`); } else { @@ -136,7 +134,6 @@ export class ApplicationServerListener { } } } - } } @@ -214,7 +211,7 @@ export class ApplicationServer { if (this.started) throw new Error('ApplicationServer already started'); this.started = true; - if (cluster.isMaster) { + if (cluster.isMaster && this.config.logStartup) { if (this.config.workers) { this.logger.log(`Start server, using ${this.config.workers} workers ...`); } else { @@ -341,7 +338,7 @@ export class ApplicationServer { await this.eventDispatcher.dispatch(onServerMainBootstrapDone, new ServerBootstrapEvent()); } - if (cluster.isMaster) { + if (cluster.isMaster && this.config.logStartup) { this.logger.log(`Server started.`); } diff --git a/packages/framework/src/module.config.ts b/packages/framework/src/module.config.ts index 5ce4998c3..a17585d7f 100644 --- a/packages/framework/src/module.config.ts +++ b/packages/framework/src/module.config.ts @@ -159,4 +159,9 @@ export class FrameworkConfig { * @see HttpConfig */ http: HttpConfig = new HttpConfig; + + /** + * If true logs all routes and rpc controllers on startup. + */ + logStartup: boolean = true; } diff --git a/packages/framework/src/module.ts b/packages/framework/src/module.ts index a2d830d62..528a2309c 100644 --- a/packages/framework/src/module.ts +++ b/packages/framework/src/module.ts @@ -11,7 +11,7 @@ import { ClassType, isClass, isPrototypeOfBase, ProcessLocker } from '@deepkit/core'; import { EventDispatcher } from '@deepkit/event'; import { isAbsolute, join } from 'path'; -import { ApplicationServer, ApplicationServerListener, onServerShutdown } from './application-server.js'; +import { ApplicationServer, LogStartupListener, onServerShutdown } from './application-server.js'; import { DebugRouterController } from './cli/debug-router.js'; import { DebugDIController } from './cli/debug-di.js'; import { ServerStartController } from './cli/server-start.js'; @@ -108,7 +108,6 @@ export class FrameworkModule extends createModuleClass({ { provide: RpcKernelConnection, scope: 'rpc', useValue: undefined }, ], listeners: [ - ApplicationServerListener, DatabaseListener, BrokerListener, ], @@ -182,6 +181,10 @@ export class FrameworkModule extends createModuleClass({ this.getImportedModuleByClass(HttpModule).configure(this.config.http); + if (this.config.logStartup) { + this.addListener(LogStartupListener); + } + if (this.config.publicDir) { const localPublicDir = isAbsolute(this.config.publicDir) ? this.config.publicDir : From 7402f117c230a6f23e892d0e5e91534f1b0bf005 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Sat, 15 Mar 2025 01:15:14 +0100 Subject: [PATCH 02/10] feat(injector): better docs --- packages/injector/src/injector.ts | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/injector/src/injector.ts b/packages/injector/src/injector.ts index 1c537f79e..c7ac3cf54 100644 --- a/packages/injector/src/injector.ts +++ b/packages/injector/src/injector.ts @@ -952,27 +952,49 @@ export class InjectorContext { ) { } + /** + * Returns a resolver for the given token. The returned resolver can + * be executed to resolve the token. This increases performance in hot paths. + */ resolve(module?: InjectorModule, type?: ReceiveType | Token): Resolver { return this.getInjector(module || this.rootModule).getResolver(type) as Resolver; } + /** + * Returns an instance of the given token or type from the injector associated with the specified module. + * + * If there is no provider for the token or the provider returns undefined, it returns undefined. + */ getOrUndefined(token?: ReceiveType | Token, module?: InjectorModule): ResolveToken | undefined { - try { - return this.get(token, module); - } catch (error) { - return; - } + const injector = this.getInjector(module || this.rootModule); + return injector.get(token, this.scope, true); } + /** + * Returns an instance of the given token or type from the injector associated with the specified module. + * + * If there is no provider for the token or the provider returns undefined, it throws an error. + */ get(token?: ReceiveType | Token, module?: InjectorModule): ResolveToken { const injector = this.getInjector(module || this.rootModule); return injector.get(token, this.scope); } + /** + * Returns the instantiation count of the given token. + * + * This is either 0 or 1 for normal providers, and >= 0 for transient providers. + */ instantiationCount(token: Token, module?: InjectorModule, scope?: string): number { return this.getInjector(module || this.rootModule).instantiationCount(token, this.scope ? this.scope.name : scope); } + /** + * Sets a value for the given token in the injector associated with the specified module. + * + * This is useful for scoped providers like HttpRequest that are created dynamically + * outside the injector container and need to be injected into services. + */ set(token: T, value: any, module?: InjectorModule): void { return this.getInjector(module || this.rootModule).set( getContainerToken(token), From 210bd844e604a74502b1875e7a6794b059138d46 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 17:55:56 +0200 Subject: [PATCH 03/10] feat(bench): new @deepkit/bench package for doing benchmarks --- packages/bench/.npmignore | 1 + packages/bench/README.md | 18 +++ packages/bench/dist/.gitkeep | 0 packages/bench/index.ts | 259 +++++++++++++++++++++++++++++++ packages/bench/package.json | 26 ++++ packages/bench/tsconfig.esm.json | 7 + packages/bench/tsconfig.json | 31 ++++ 7 files changed, 342 insertions(+) create mode 100644 packages/bench/.npmignore create mode 100644 packages/bench/README.md create mode 100644 packages/bench/dist/.gitkeep create mode 100644 packages/bench/index.ts create mode 100644 packages/bench/package.json create mode 100644 packages/bench/tsconfig.esm.json create mode 100644 packages/bench/tsconfig.json diff --git a/packages/bench/.npmignore b/packages/bench/.npmignore new file mode 100644 index 000000000..2b29f2764 --- /dev/null +++ b/packages/bench/.npmignore @@ -0,0 +1 @@ +tests diff --git a/packages/bench/README.md b/packages/bench/README.md new file mode 100644 index 000000000..a3eca41ac --- /dev/null +++ b/packages/bench/README.md @@ -0,0 +1,18 @@ +# Bench + +```typescript +// benchmarks/test.ts +import { benchmark, run } from '@deepkit/bench'; + +let i = 0; + +benchmark('test', () => { + i += 10; +}); + +void run(); +``` + +```sh +node --import @deepkit/run benchmarks/test.ts +``` diff --git a/packages/bench/dist/.gitkeep b/packages/bench/dist/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/bench/index.ts b/packages/bench/index.ts new file mode 100644 index 000000000..093d9725e --- /dev/null +++ b/packages/bench/index.ts @@ -0,0 +1,259 @@ +export const AsyncFunction = (async () => { +}).constructor as { new(...args: string[]): Function }; + +function noop() { +} + +type Benchmark = { + name: string; + fn: () => void; + iterations: number; + avgTime: number; + variance: number; + rme: number; + samples: number[], + heapDiff: number; + gcEvents: number[]; +} + +const benchmarks: Benchmark[] = [{ + name: '', + fn: noop, + gcEvents: [], + samples: [], + iterations: 0, + avgTime: 0, + heapDiff: 0, + rme: 0, + variance: 0, +}]; +let benchmarkCurrent = 1; +let current = benchmarks[0]; + +const blocks = ['▁', '▂', '▄', '▅', '▆', '▇', '█']; + +function getBlocks(stats: number[]): string { + const max = Math.max(...stats); + let res = ''; + for (const n of stats) { + const cat = Math.ceil(n / max * 6); + res += (blocks[cat - 1]); + } + + return res; +} + +const Reset = '\x1b[0m'; +const FgGreen = '\x1b[32m'; +const FgYellow = '\x1b[33m'; + +function green(text: string): string { + return `${FgGreen}${text}${Reset}`; +} + +function yellow(text: string): string { + return `${FgYellow}${text}${Reset}`; +} + +function print(...args: any[]) { + process.stdout.write(args.join(' ') + '\n'); +} + +const callGc = global.gc ? global.gc : () => undefined; + +function report(benchmark: Benchmark) { + const hz = 1000 / benchmark.avgTime; + + print( + ' 🏎', + 'x', green(hz.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(14)), 'ops/sec', + '\xb1' + benchmark.rme.toFixed(2).padStart(5) + '%', + yellow(benchmark.avgTime.toLocaleString(undefined, { minimumFractionDigits: 6, maximumFractionDigits: 6 }).padStart(10)), 'ms/op', + '\t' + getBlocks(benchmark.samples), + green(benchmark.name) + (current.fn instanceof AsyncFunction ? ' (async)' : ''), + `\t${benchmark.iterations} samples`, + benchmark.gcEvents.length ? `\t${benchmark.gcEvents.length} gc (${benchmark.gcEvents.reduce((a, b) => a + b, 0)}ms)` : '', + ); +} + +export function benchmark(name: string, fn: () => void) { + benchmarks.push({ + name, fn, + gcEvents: [], + samples: [], + iterations: 0, + avgTime: 0, + heapDiff: 0, + rme: 0, + variance: 0, + }); +} + +export async function run(seconds: number = 1) { + print('Node', process.version); + + while (benchmarkCurrent < benchmarks.length) { + current = benchmarks[benchmarkCurrent]; + try { + if (current.fn instanceof AsyncFunction) { + await testAsync(seconds); + } else { + test(seconds); + } + } catch (error) { + print(`Benchmark ${current.name} failed`, error); + } + benchmarkCurrent++; + report(current); + } + + console.log('done'); +} + +const executors = [ + getExecutor(1), + getExecutor(10), + getExecutor(100), + getExecutor(1000), + getExecutor(10000), + getExecutor(100000), + getExecutor(1000000), +]; + +const asyncExecutors = [ + getAsyncExecutor(1), + getAsyncExecutor(10), + getAsyncExecutor(100), + getAsyncExecutor(1000), + getAsyncExecutor(10000), + getAsyncExecutor(100000), + getAsyncExecutor(1000000), +]; + +const gcObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + current.gcEvents.push(entry.duration); + } +}); +const a = gcObserver.observe({ entryTypes: ['gc'] }); + +function test(seconds: number) { + let iterations = 1; + let samples: number[] = []; + const max = seconds * 1000; + + let executorId = 0; + let executor = executors[executorId]; + //check which executor to use, go up until one round takes more than 5ms + do { + const candidate = executors[executorId++]; + if (!candidate) break; + const start = performance.now(); + candidate(current.fn); + const end = performance.now(); + const time = end - start; + if (time > 5) break; + executor = candidate; + } while (true); + + // warmup + for (let i = 0; i < 100; i++) { + executor(current.fn); + } + + let consumed = 0; + const beforeHeap = process.memoryUsage().heapUsed; + callGc(); + do { + const start = performance.now(); + const r = executor(current.fn); + const end = performance.now(); + const time = end - start; + consumed += time; + samples.push(time / r); + iterations += r; + } while (consumed < max); + + // console.log('executionTimes', executionTimes); + collect(current, beforeHeap, samples, iterations); +} + +function collect(current: Benchmark, beforeHeap: number, samples: number[], iterations: number) { + // remove first 10% of samples + const allSamples = samples.slice(); + samples = samples.slice(Math.floor(samples.length * 0.9)); + + const avgTime = samples.reduce((sum, t) => sum + t, 0) / samples.length; + samples.sort((a, b) => a - b); + + const variance = samples.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / samples.length; + const rme = (Math.sqrt(variance) / avgTime) * 100; // Relative Margin of Error (RME) + + const afterHeap = process.memoryUsage().heapUsed; + const heapDiff = afterHeap - beforeHeap; + + current.avgTime = avgTime; + current.variance = variance; + current.rme = rme; + current.heapDiff = heapDiff; + current.iterations = iterations; + // pick 20 samples from allSamples, make sure the first and last are included + current.samples = allSamples.filter((v, i) => i === 0 || i === allSamples.length - 1 || i % Math.floor(allSamples.length / 20) === 0); + // current.samples = allSamples; +} + +async function testAsync(seconds: number) { + let iterations = 1; + let samples: number[] = []; + const max = seconds * 1000; + + let executorId = 0; + let executor = asyncExecutors[executorId]; + //check which executor to use, go up until one round takes more than 5ms + do { + const candidate = asyncExecutors[executorId++]; + if (!candidate) break; + const start = performance.now(); + await candidate(current.fn); + const end = performance.now(); + const time = end - start; + if (time > 5) break; + executor = candidate; + } while (true); + + // warmup + for (let i = 0; i < 100; i++) { + executor(current.fn); + } + + let consumed = 0; + const beforeHeap = process.memoryUsage().heapUsed; + callGc(); + do { + const start = performance.now(); + const r = await executor(current.fn); + const end = performance.now(); + const time = end - start; + consumed += time; + samples.push(time / r); + iterations += r; + } while (consumed < max); + + collect(current, beforeHeap, samples, iterations); +} + +function getExecutor(times: number) { + let code = ''; + for (let i = 0; i < times; i++) { + code += 'fn();'; + } + return new Function('fn', code + '; return ' + times); +} + +function getAsyncExecutor(times: number) { + let code = ''; + for (let i = 0; i < times; i++) { + code += 'await fn();'; + } + return new AsyncFunction('fn', code + '; return ' + times); +} diff --git a/packages/bench/package.json b/packages/bench/package.json new file mode 100644 index 000000000..3ca959938 --- /dev/null +++ b/packages/bench/package.json @@ -0,0 +1,26 @@ +{ + "name": "@deepkit/bench", + "version": "1.0.3", + "description": "Deepkit Bench", + "type": "commonjs", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "types": "./dist/cjs/index.d.ts", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + } + }, + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "echo '{\"type\": \"module\"}' > ./dist/esm/package.json" + }, + "repository": "https://github.com/deepkit/deepkit-framework", + "author": "Marc J. Schmidt ", + "license": "MIT" +} diff --git a/packages/bench/tsconfig.esm.json b/packages/bench/tsconfig.esm.json new file mode 100644 index 000000000..f2600f726 --- /dev/null +++ b/packages/bench/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "ES2020" + } +} diff --git a/packages/bench/tsconfig.json b/packages/bench/tsconfig.json new file mode 100644 index 000000000..eb45310ab --- /dev/null +++ b/packages/bench/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "noImplicitAny": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "moduleResolution": "node", + "target": "es2020", + "module": "CommonJS", + "esModuleInterop": true, + "outDir": "./dist/cjs", + "declaration": true, + "composite": true, + "types": [ + "node" + ] + }, + "reflection": true, + "include": [ + "index.ts" + ], + "exclude": [ + "tests" + ], + "references": [ + ] +} From 0b9246edaab9deb80b6855cba90f48baa4562e08 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 17:57:27 +0200 Subject: [PATCH 04/10] feat(run): new @deepkit/run package to run typescript easily this is only used internally --- packages/run/.npmignore | 1 + packages/run/README.md | 5 +++ packages/run/dist/.gitkeep | 0 packages/run/hooks.ts | 57 ++++++++++++++++++++++++++++ packages/run/index.ts | 4 ++ packages/run/package.json | 29 ++++++++++++++ packages/run/tsconfig.esm.json | 9 +++++ packages/run/tsconfig.json | 31 +++++++++++++++ packages/type-compiler/src/config.ts | 3 +- packages/type/src/core.ts | 1 - packages/type/src/reflection/type.ts | 2 +- yarn.lock | 16 ++++++++ 12 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 packages/run/.npmignore create mode 100644 packages/run/README.md create mode 100644 packages/run/dist/.gitkeep create mode 100644 packages/run/hooks.ts create mode 100644 packages/run/index.ts create mode 100644 packages/run/package.json create mode 100644 packages/run/tsconfig.esm.json create mode 100644 packages/run/tsconfig.json diff --git a/packages/run/.npmignore b/packages/run/.npmignore new file mode 100644 index 000000000..2b29f2764 --- /dev/null +++ b/packages/run/.npmignore @@ -0,0 +1 @@ +tests diff --git a/packages/run/README.md b/packages/run/README.md new file mode 100644 index 000000000..29c5e80f3 --- /dev/null +++ b/packages/run/README.md @@ -0,0 +1,5 @@ +# Run + +```sh +node --import @deepkit/run app.ts +``` diff --git a/packages/run/dist/.gitkeep b/packages/run/dist/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/run/hooks.ts b/packages/run/hooks.ts new file mode 100644 index 000000000..256ff6bc0 --- /dev/null +++ b/packages/run/hooks.ts @@ -0,0 +1,57 @@ +import { ModuleKind, readConfigFile, ScriptTarget, transpile } from 'typescript'; +import { existsSync, readFileSync } from 'fs'; +import { dirname, extname } from 'path'; +import { readFile, stat } from 'node:fs/promises'; + +let tsConfigPath = 'tsconfig.json'; +let currentPath = process.cwd(); + +while (currentPath !== '/') { + const path = `${currentPath}/tsconfig.json`; + if (existsSync(path)) { + tsConfigPath = path; + break; + } + const next = dirname(currentPath); + if (next === currentPath) break; + currentPath = next; +} + +const tsConfig = readConfigFile(tsConfigPath, (path) => readFileSync(path, 'utf8')); +const tsConfigNormalized = Object.assign({}, tsConfig?.config.compilerOptions || {}, { + module: ModuleKind.ES2022, // Keep as ESNext for ESM support + target: ScriptTarget.ES2022, // Transpile to ES2020+ for modern ESM support + configFilePath: tsConfigPath, + sourceMap: true, +}); + +async function tryResolveTs(specifier, context, nextResolve) { + if (extname(specifier) === '.js') { + const tsSpecifier = specifier.replace(/\.js$/, '.ts'); + try { + // Check if the .ts file exists before resolving + await stat(new URL(tsSpecifier, context.parentURL)); + return nextResolve(tsSpecifier, context); + } catch { + // If no .ts file is found, fall back to the default resolution + } + } + if (extname(specifier) === '.ts') { + return { url: specifier, shortCircuit: true }; + } + return nextResolve(specifier, context); +} + +export async function resolve(specifier, context, defaultResolve) { + return tryResolveTs(specifier, context, defaultResolve); +} + +export async function load(url, context, nextLoad) { + if (extname(url) === '.ts') { + const path = new URL(url).pathname; + const source = await readFile(path, 'utf8'); + const transpiled = transpile(source, tsConfigNormalized, path); + return { format: 'module', source: transpiled, shortCircuit: true }; + } + return nextLoad(url); +} diff --git a/packages/run/index.ts b/packages/run/index.ts new file mode 100644 index 000000000..7d25ceaa5 --- /dev/null +++ b/packages/run/index.ts @@ -0,0 +1,4 @@ +import { register } from 'node:module'; + +// @ts-ignore +register('./hooks.js', import.meta.url); diff --git a/packages/run/package.json b/packages/run/package.json new file mode 100644 index 000000000..e18f168f8 --- /dev/null +++ b/packages/run/package.json @@ -0,0 +1,29 @@ +{ + "name": "@deepkit/run", + "version": "1.0.3", + "description": "Deepkit Run", + "module": "./dist/esm/index.js", + "main": "./dist/cjs/index.js", + "types": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "types": "./dist/cjs/index.d.ts", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + } + }, + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "echo '{\"type\": \"module\"}' > ./dist/esm/package.json" + }, + "repository": "https://github.com/deepkit/deepkit-framework", + "author": "Marc J. Schmidt ", + "license": "MIT", + "dependencies": { + "ts-node": "^10.9.1", + "typescript": "~5.7.3" + } +} diff --git a/packages/run/tsconfig.esm.json b/packages/run/tsconfig.esm.json new file mode 100644 index 000000000..26b81a50b --- /dev/null +++ b/packages/run/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "ES2020" + }, + "references": [ + ] +} diff --git a/packages/run/tsconfig.json b/packages/run/tsconfig.json new file mode 100644 index 000000000..65dd35926 --- /dev/null +++ b/packages/run/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "noImplicitAny": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "target": "es2020", + "module": "CommonJS", + "esModuleInterop": true, + "outDir": "./dist/cjs", + "declaration": true, + "composite": true, + "types": [ + "node" + ] + }, + "reflection": true, + "include": [ + "index.ts", + "hooks.ts" + ], + "exclude": [ + "tests" + ], + "references": [ + ] +} diff --git a/packages/type-compiler/src/config.ts b/packages/type-compiler/src/config.ts index be3dbca9f..4e21ca555 100644 --- a/packages/type-compiler/src/config.ts +++ b/packages/type-compiler/src/config.ts @@ -304,7 +304,8 @@ export function getConfigResolver( const resolvedConfig: ResolvedConfig = { path: tsConfigPath, - compilerOptions: config.compilerOptions, + // we want to maintain options passed from tsc API (transpile, Program) + compilerOptions: Object.assign(config.compilerOptions, compilerOptions), exclude: config.exclude, reflection: config.reflection, mergeStrategy: config.mergeStrategy || defaultMergeStrategy, diff --git a/packages/type/src/core.ts b/packages/type/src/core.ts index f86e82199..710b17328 100644 --- a/packages/type/src/core.ts +++ b/packages/type/src/core.ts @@ -144,7 +144,6 @@ export function arrayBufferFrom(data: string, encoding?: string): ArrayBuffer { return nodeBufferToArrayBuffer(Buffer.from(data, encoding as any)); } - /** * Same as Buffer.from(arrayBuffer).toString(encoding), but more in line with the current API. */ diff --git a/packages/type/src/reflection/type.ts b/packages/type/src/reflection/type.ts index f6ed69f28..4860a49fc 100644 --- a/packages/type/src/reflection/type.ts +++ b/packages/type/src/reflection/type.ts @@ -552,7 +552,7 @@ export type FindType = T extends export type InlineRuntimeType | Type | number | string | boolean | bigint> = T extends ReflectionClass ? K : any; export function isType(entry: any): entry is Type { - return 'object' === typeof entry && entry.constructor === Object && 'kind' in entry && 'number' === typeof entry.kind; + return entry && 'object' === typeof entry && 'number' === typeof entry.kind; } export function isBinary(type: Type): boolean { diff --git a/yarn.lock b/yarn.lock index 7f8b058f4..0e64807ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2951,6 +2951,12 @@ __metadata: languageName: unknown linkType: soft +"@deepkit/bench@npm:^1.0.3, @deepkit/bench@workspace:packages/bench": + version: 0.0.0-use.local + resolution: "@deepkit/bench@workspace:packages/bench" + languageName: unknown + linkType: soft + "@deepkit/broker@npm:^1.0.1, @deepkit/broker@npm:^1.0.4, @deepkit/broker@workspace:packages/broker": version: 0.0.0-use.local resolution: "@deepkit/broker@workspace:packages/broker" @@ -3458,6 +3464,7 @@ __metadata: version: 0.0.0-use.local resolution: "@deepkit/injector@workspace:packages/injector" dependencies: + "@deepkit/bench": "npm:^1.0.3" "@deepkit/core": "npm:^1.0.3" "@deepkit/type": "npm:^1.0.3" benchmark: "npm:^2.1.4" @@ -3723,6 +3730,15 @@ __metadata: languageName: unknown linkType: soft +"@deepkit/run@workspace:packages/run": + version: 0.0.0-use.local + resolution: "@deepkit/run@workspace:packages/run" + dependencies: + ts-node: "npm:^10.9.1" + typescript: "npm:~5.7.3" + languageName: unknown + linkType: soft + "@deepkit/skeletion@workspace:packages/skeleton": version: 0.0.0-use.local resolution: "@deepkit/skeletion@workspace:packages/skeleton" From f295c5e77507f234ae2ee3cffdd55561ef294998 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 18:04:02 +0200 Subject: [PATCH 05/10] feat(injector): refactor internal code to get big performance improvement deepkit/injector was rewritten in its core. We have a lot of performance improvements: 2-4x improvement for very small containers (with less than 5 providers) 300x improvement (and more) for bigger containers with like 1000-2000 providers. the old version used a big switch-case, the new one direct references In general getting the reference to the DI factory directly allows to get the most performance increase. Doing it like this ``` const resolveService = injector.resolve(Service); const service = resolveService(); // direct reference to JIT factory ``` makes resolving dependencies very fast. By assigning the factory function now directly into dependency resolution makes the whole container almost optimal in performance. Currently, it hovers around 100mio ops/s, which is nearly identical to doing return `instance ||= new Service()`; --- packages/app/tests/module.spec.ts | 2 +- packages/app/tests/service-container.spec.ts | 2 +- packages/http/src/http.ts | 8 +- packages/http/src/kernel.ts | 17 +- packages/injector/benchmarks/factory.ts | 371 +++++++ packages/injector/benchmarks/injector.ts | 60 ++ packages/injector/package.json | 1 + packages/injector/src/injector.ts | 1001 +++++++++++++----- packages/injector/src/module.ts | 4 +- packages/injector/src/provider.ts | 22 +- packages/injector/tests/injector.spec.ts | 56 +- packages/injector/tests/injector2.spec.ts | 53 +- packages/injector/tests/injector3.spec.ts | 251 +++++ packages/injector/tests/nominal.spec.ts | 5 +- 14 files changed, 1529 insertions(+), 324 deletions(-) create mode 100644 packages/injector/benchmarks/factory.ts create mode 100644 packages/injector/benchmarks/injector.ts create mode 100644 packages/injector/tests/injector3.spec.ts diff --git a/packages/app/tests/module.spec.ts b/packages/app/tests/module.spec.ts index 0647f5ea5..943e908cd 100644 --- a/packages/app/tests/module.spec.ts +++ b/packages/app/tests/module.spec.ts @@ -387,7 +387,7 @@ test('scoped injector', () => { { const injector = serviceContainer.getInjector(module); - expect(() => injector.get(Service)).toThrow(`Service 'Service' is known but has no value`); + expect(() => injector.get(Service)).toThrow(`Service 'Service' is known but is not available in scope global. Available in scopes: http`); } { diff --git a/packages/app/tests/service-container.spec.ts b/packages/app/tests/service-container.spec.ts index f6e11d672..90a0615aa 100644 --- a/packages/app/tests/service-container.spec.ts +++ b/packages/app/tests/service-container.spec.ts @@ -118,7 +118,7 @@ test('scopes', () => { const serviceContainer = new ServiceContainer(myModule); const sessionInjector = serviceContainer.getInjectorContext().createChildScope('rpc'); - expect(() => serviceContainer.getInjectorContext().get(SessionHandler)).toThrow(`Service 'SessionHandler' is known but has no value`); + expect(() => serviceContainer.getInjectorContext().get(SessionHandler)).toThrow(`Service 'SessionHandler' is known but is not available in scope global. Available in scopes: rpc`); expect(sessionInjector.get(SessionHandler)).toBeInstanceOf(SessionHandler); expect(serviceContainer.getInjectorContext().get(MyService)).toBeInstanceOf(MyService); diff --git a/packages/http/src/http.ts b/packages/http/src/http.ts index d92d37d6a..0f3319af5 100644 --- a/packages/http/src/http.ts +++ b/packages/http/src/http.ts @@ -12,7 +12,7 @@ import { asyncOperation, ClassType, CustomError, getClassName, getClassTypeFromI import { OutgoingHttpHeaders, ServerResponse } from 'http'; import { BaseEvent, eventDispatcher } from '@deepkit/event'; import { HttpRequest, HttpRequestPositionedParameters, HttpResponse } from './model.js'; -import { InjectorContext } from '@deepkit/injector'; +import { Injector, InjectorContext, Setter } from '@deepkit/injector'; import { LoggerInterface } from '@deepkit/logger'; import { HttpRouter, RouteConfig, RouteParameterResolverForInjector } from './router.js'; import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; @@ -536,12 +536,16 @@ export class HttpResultFormatter { } export class HttpListener { + protected setRouteConfig: Setter; + constructor( protected router: HttpRouter, protected logger: LoggerInterface, protected resultFormatter: HttpResultFormatter, + protected injector: Injector, protected stopwatch?: Stopwatch, ) { + this.setRouteConfig = injector.createSetter(RouteConfig, {name: 'http'}); } @eventDispatcher.listen(httpWorkflow.onRequest, 100) @@ -617,7 +621,7 @@ export class HttpListener { } } - event.injectorContext.set(RouteConfig, resolved.routeConfig); + this.setRouteConfig(resolved.routeConfig, event.injectorContext.scope); event.routeFound(resolved.routeConfig, resolved.parameters); } } catch (error) { diff --git a/packages/http/src/kernel.ts b/packages/http/src/kernel.ts index a2dc08b83..b7f257ede 100644 --- a/packages/http/src/kernel.ts +++ b/packages/http/src/kernel.ts @@ -1,4 +1,4 @@ -import { InjectorContext } from '@deepkit/injector'; +import { InjectorContext, Setter } from '@deepkit/injector'; import { HttpRouter } from './router.js'; import { EventDispatcher } from '@deepkit/event'; import { LoggerInterface } from '@deepkit/logger'; @@ -34,6 +34,10 @@ interface HttpKernelMiddlewareOptions extends HttpKernelHandleOptions { } export class HttpKernel { + protected setHttpRequest: Setter; + protected setHttpResponse: Setter; + protected setInjectorContext: Setter; + constructor( protected router: HttpRouter, protected eventDispatcher: EventDispatcher, @@ -41,6 +45,9 @@ export class HttpKernel { protected logger: LoggerInterface, protected stopwatch?: Stopwatch, ) { + this.setHttpRequest = injectorContext.setter(undefined, HttpRequest); + this.setHttpResponse = injectorContext.setter(undefined, HttpResponse); + this.setInjectorContext = injectorContext.setter(undefined, InjectorContext); } /** @@ -98,9 +105,11 @@ export class HttpKernel { const httpInjectorContext = this.injectorContext.createChildScope('http'); const req = incomingMessageToHttpRequest(_req); const res = serverResponseToHttpResponse(_res); - httpInjectorContext.set(HttpRequest, req); - httpInjectorContext.set(HttpResponse, res); - httpInjectorContext.set(InjectorContext, httpInjectorContext); + + this.setHttpRequest(req, httpInjectorContext.scope); + this.setHttpResponse(res, httpInjectorContext.scope); + this.setInjectorContext(httpInjectorContext, httpInjectorContext.scope); + req.throwErrorOnNotFound = options.throwOnNotFound || false; const frame = this.stopwatch ? this.stopwatch.start(req.method + ' ' + req.getUrl(), FrameCategory.http, true) : undefined; diff --git a/packages/injector/benchmarks/factory.ts b/packages/injector/benchmarks/factory.ts new file mode 100644 index 000000000..bcb03d042 --- /dev/null +++ b/packages/injector/benchmarks/factory.ts @@ -0,0 +1,371 @@ +import { benchmark, run } from '@deepkit/bench'; +import { InjectorContext } from '../src/injector.js'; +import { InjectorModule } from '../src/module.js'; +import { ClassType, CompilerContext, getClassName } from '@deepkit/core'; + +class ServiceA { +} + +class ServiceB { + constructor(public serviceA: ServiceA) { + } +} + +class ScopedServiceC { + constructor(public serviceA: ServiceA) { + } +} + +function createInjector1() { + function serviceAFactory() { + const instance = new ServiceA(); + serviceA = () => instance; + return instance; + } + + let serviceA = serviceAFactory; + + function serviceBFactory() { + const instance = new ServiceB(serviceA()); + serviceB = () => instance; + return instance; + } + + let serviceB = serviceBFactory; + + return { serviceA, serviceB }; +} + +function createInjector2() { + const instances: any = {}; + const instances2: any = {}; + + const A = { creating: 0, count: 0 }; + const B = { creating: 0, count: 0 }; + + function reset() { + A.creating = 0; + B.creating = 0; + C.creating = 0; + state.creating = 1; + } + + function serviceA() { + if (instances.A) return instances.A; + if (A.creating) throw new Error('circular dependency'); + A.creating = state.creating++; + instances.A = new ServiceA(); + A.creating = 0; + return instances.A; + } + + function serviceB() { + if (instances2.B) return instances2.B; + if (B.creating) throw new Error('circular dependency'); + B.creating = state.creating++; + instances2.B = new ServiceB(serviceA()); + B.creating = 0; + return instances2.B; + } + + const state = { + faulty: 0, + creating: 1, + }; + + const C = { + creating: 0, + count: 0, + }; + + // todo; test this shit with many services + // make a function to generate this code automatically based of an array + function scopedServiceC(instances: any) { + if (instances.C) return instances.C; + if (C.creating) { + reset(); + throw new Error('circular dependency'); + } + if (state.faulty) reset(); + C.creating = state.creating++; + state.faulty++; + C.count++; + instances.C = new ScopedServiceC(serviceA()); + state.faulty--; + C.creating = 0; + return instances.C; + } + + function resolve(id: any) { + switch (id) { + case ScopedServiceC: + return scopedServiceC; + case ServiceA: + return serviceA; + case ServiceB: + return serviceB; + } + } + + function circularCalls() { + // const calls: { when: number, what: string }[] = []; + // if (instanceACreating) calls.push({ when: instanceACreating, what: 'ServiceA' }); + // if (instanceBCreating) calls.push({ when: instanceBCreating, what: 'ServiceB' }); + // if (instanceCCreating) calls.push({ when: instanceCCreating, what: 'ScopedServiceC' }); + // calls.sort((a, b) => a.when - b.when); + // return calls; + } + + function get(token: any, scope?: any) { + switch (token) { + case ScopedServiceC: + return scopedServiceC(scope); + case ServiceA: + return serviceA(); + case ServiceB: + return serviceB(); + } + } + + return { get, resolve }; +} + +interface Injector { + resolve(token: any): (scope?: any) => any; + + get(token: any, scope?: any): any; +} + +function createInjector3(providers: { provide: ClassType, scope?: string }[]): Injector { + const compiler = new CompilerContext(); + const factories: string[] = []; + const init: string[] = []; + const resolver: string[] = []; + + const exports: string[] = []; + const get: string[] = []; + let idx = 0; + let ids = 0; + + function getName(type: ClassType) { + return getClassName(type) + ids++; + } + + const normalized = providers.map(v => ({ + classType: v.provide, + scope: v.scope || '', + name: getName(v.provide), + })); + + for (const provider of normalized) { + const classTypeVar = compiler.reserveVariable(provider.name, provider.classType); + + const state = `s${provider.name}`; + const instance = `instances.${provider.name}`; + const arg = provider.scope ? 'instances' : ''; + const check = provider.scope ? `if (instances.name !== ${JSON.stringify(provider.scope)}) throw new Error('scope not found');` : ''; + + factories.push(` + function factory${provider.name}(${arg}) { + ${check} + if (${instance}) return ${instance}; + if (${state}.creating) { + reset(); + throw new Error('circular dependency'); + } + if (state.faulty) reset(); + ${state}.creating = state.creating++; + state.faulty++; + ${state}.count++; + ${instance} = new ${classTypeVar}(); + state.faulty--; + ${state}.creating = 0; + return ${instance}; + } + ${classTypeVar}[symbol] = factory${provider.name}; + `); + + resolver.push(`case ${classTypeVar}: return factory${provider.name};`); + + init.push(` + const ${state} = { + count: 0, + creating: 0, + }; + `); + } + + // const resolveToken = `switch (token) { ${resolver.join('\n')}}`; + // const resolveToken = `const f = token.f; if (f) return f;` + // const resolveToken = `return map.get(token);`; + const resolveToken = ` + const fn = token[symbol]; + if (fn) return fn; + switch (token) { + + } + `; + + return compiler.build(` + const instances = {}; + const state = { + faulty: 0, + creating: 1, + }; + + const symbol = Symbol('injector'); + + function reset() { + } + + ${init.join('\n')} + ${factories.join('\n')} + + function resolve(token) { + ${resolveToken} + throw new Error('No provider found for ' + token); + } + + function get(token, scope) { + return resolve(token)(scope); + throw new Error('No provider found for ' + token); + } + + return { resolve, get }; + `)(); +} + +const providers = [ + { provide: ServiceA }, + { provide: ServiceB }, + { provide: ScopedServiceC, scope: 'rpc' }, +]; + +for (let i = 0; i < 200; i++) { + class Service { + } + + providers.unshift({ provide: Service }); +} + +console.log(`${providers.length} providers`); +const module = new InjectorModule(providers); +const injector = new InjectorContext(module); +const injector1 = createInjector1(); +const injector2 = createInjector2(); +const injector3 = createInjector3(providers); + +const a = injector.get(ServiceA); +const b = injector.get(ServiceB); + +// if (!(a instanceof ServiceA)) throw new Error('a is not ServiceA'); +// if (!(b instanceof ServiceB)) throw new Error('b is not ServiceB'); + +// benchmark('injector1', () => { +// injector1.serviceA(); +// injector1.serviceB(); +// }); + +benchmark('injector.get', () => { + injector.get(ServiceA); +}); + +const resolver1 = injector.resolve(undefined, ServiceA); + +benchmark('injector resolver', () => { + resolver1(); +}); + +const scope1 = injector.createChildScope('rpc'); +scope1.get(ScopedServiceC); + +benchmark('injector scope create', () => { + const scope = injector.createChildScope('rpc'); +}); + +benchmark('injector scope create & get', () => { + const scope = injector.createChildScope('rpc'); + scope.get(ScopedServiceC); +}); + +const resolvedScopedServiceC = injector.resolve(undefined, ScopedServiceC); +const scope = injector.createChildScope('rpc'); +resolvedScopedServiceC(scope.scope); + +benchmark('injector scope create & resolver', () => { + const scope = injector.createChildScope('rpc'); + resolvedScopedServiceC(scope.scope); +}); + +const injectors = [ + // { name: 'injector2', injector: injector2 }, + { name: 'injector3', injector: injector3 }, +]; + +for (const i of injectors) { + const injector = i.injector; + benchmark(`${i.name}`, () => { + injector.get(ServiceA); + }); + + const resolveA = injector.resolve(ServiceA); + + benchmark(`${i.name} resolver`, () => { + resolveA(); + }); + + benchmark(`${i.name} scope create`, () => { + // const scope = injector2.scopeRpc(); + const scope = {}; + }); + + benchmark(`${i.name} scope create & get`, () => { + // const scope = injector2.scopeRpc(); + // injector2.scopedServiceC(scope); + const scope = {name: 'rpc'}; + injector.get(ScopedServiceC, scope); + }); + + const resolveC = injector.resolve(ScopedServiceC); + benchmark(`${i.name} scope create & resolver`, () => { + // const scope = injector2.scopeRpc(); + // injector2.scopedServiceC(scope); + const scope = {name: 'rpc'}; + resolveC(scope); + }); + + const serviceA = new ServiceA(); + + benchmark(`${i.name} scope create & get baseline`, () => { + const scope: any = {}; + scope.service ||= new ScopedServiceC(serviceA); + }); + + const scope = {name: 'rpc'}; + benchmark(`${i.name} scope get`, () => { + injector.get(ScopedServiceC, scope); + }); + + const resolve = injector.resolve(ScopedServiceC); + + benchmark(`${i.name} scope resolve`, () => { + resolve(scope); + }); +} + +// const state: any = {}; +// +// benchmark('baseline', () => { +// if (!state.instanceA) state.instanceA = new ServiceA(); +// if (!state.instanceB) state.instanceB = new ServiceB(state.instanceA); +// }); +// +// let instanceA = {}; +// let instanceB = {}; +// +// benchmark('baseline2', () => { +// instanceA ||= new ServiceA(); +// instanceB ||= new ServiceB(instanceA); +// }); + +run(); diff --git a/packages/injector/benchmarks/injector.ts b/packages/injector/benchmarks/injector.ts new file mode 100644 index 000000000..37c46db7b --- /dev/null +++ b/packages/injector/benchmarks/injector.ts @@ -0,0 +1,60 @@ +import { benchmark, run } from '@deepkit/bench'; +import { InjectorModule } from '../src/module.js'; +import { InjectorContext } from '../src/injector.js'; + +class Service { +} + +class Service2 { +} + +const module = new InjectorModule([ + Service, + { provide: Service2, scope: 'rpc' }, +]); + +const injector = new InjectorContext(module); + +const message = new Uint8Array(32); + + +benchmark('injector.get', () => { + injector.get(Service); +}); + +const resolver1 = injector.resolver(undefined, Service); + +benchmark('resolver', () => { + resolver1(); +}); + +benchmark('scope create', () => { + const scope = injector.createChildScope('rpc'); +}); + +const scope = injector.createChildScope('rpc'); + +benchmark('scope.get cached', () => { + scope.get(Service2); +}); + +benchmark('scope.get singleton', () => { + scope.get(Service); +}); + +benchmark('scope.get new', () => { + const scope = injector.createChildScope('rpc'); + scope.get(Service2); +}); + +const resolver2 = injector.resolver(module, Service2); + +benchmark('scope resolver', () => { + resolver2(scope.scope); +}); + +benchmark('baseline', () => { + new Service2(); +}); + +run(); diff --git a/packages/injector/package.json b/packages/injector/package.json index 11f6338bb..126af347f 100644 --- a/packages/injector/package.json +++ b/packages/injector/package.json @@ -27,6 +27,7 @@ "@deepkit/type": "^1.0.1" }, "devDependencies": { + "@deepkit/bench": "^1.0.3", "@deepkit/core": "^1.0.3", "@deepkit/type": "^1.0.3", "benchmark": "^2.1.4" diff --git a/packages/injector/src/injector.ts b/packages/injector/src/injector.ts index c7ac3cf54..52b8e6301 100644 --- a/packages/injector/src/injector.ts +++ b/packages/injector/src/injector.ts @@ -2,7 +2,6 @@ import { isClassProvider, isExistingProvider, isFactoryProvider, - isTransient, isValueProvider, NormalizedProvider, ProviderWithScope, @@ -26,8 +25,8 @@ import { import { ConfigurationProviderRegistry, ConfigureProviderEntry, findModuleForConfig, getScope, InjectorModule, PreparedProvider } from './module.js'; import { isOptional, - isType, isWithAnnotations, + Packed, ReceiveType, reflect, ReflectionClass, @@ -85,25 +84,35 @@ function constructorParameterNotFound(ofName: string, name: string, position: nu ); } -function knownServiceNotfoundError(label: string, scopes: string[], scope?: Scope) { +function knownServiceNotFoundError(label: string, scopes: string[], scope?: Scope) { throw new ServiceNotFoundError( `Service '${label}' is known but has no value.${scopes.length ? ` Available in scopes: ${scopes.join(', ')}, requested scope is ${scope ? scope.name : 'global'}.` : ''}`, ); } +function serviceNotFoundError(label: string) { + throw new ServiceNotFoundError( + `Service '${label}' not found. No matching provider.`, + ); +} + +function knownServiceNotFoundInScope(label: string, scopes: string[], scope?: Scope) { + throw new ServiceNotFoundError( + `Service '${label}' is known but is not available in scope ${scope?.name || 'global'}. Available in scopes: ${scopes.join(', ')}.`, + ); +} + function factoryDependencyNotFound(ofName: string, name: string, position: number, token: any) { const argsCheck: string[] = []; for (let i = 0; i < position; i++) argsCheck.push('✓'); argsCheck.push('?'); - for (const reset of CircularDetectorResets) reset(); throw new DependenciesUnmetError( `Unknown factory dependency argument '${tokenLabel(token)}' of ${ofName}(${argsCheck.join(', ')}). Make sure '${tokenLabel(token)}' is provided.`, ); } function propertyParameterNotFound(ofName: string, name: string, position: number, token: any) { - for (const reset of CircularDetectorResets) reset(); throw new DependenciesUnmetError( `Unknown property parameter ${name} of ${ofName}. Make sure '${tokenLabel(token)}' is provided.`, ); @@ -125,19 +134,28 @@ function createTransientInjectionTarget(destination: Destination | undefined) { return new TransientInjectionTarget(destination.token); } -const CircularDetector: any[] = []; -const CircularDetectorResets: (() => void)[] = []; +interface StackEntry { + label: string, + creation: number; + id: number; + cause: boolean; +} + +function stackToPath(stack: StackEntry[]): string[] { + const cause = stack.find(v => v.cause); + stack.sort((a, b) => a.id - b.id); + const labels = stack.map(v => v.label); + if (cause) labels.push(cause.label); + return labels; +} -function throwCircularDependency() { - const path = CircularDetector.map(tokenLabel).join(' -> '); - CircularDetector.length = 0; - for (const reset of CircularDetectorResets) reset(); +function throwCircularDependency(paths: string[]) { + const path = paths.join(' -> '); throw new CircularDependencyError(`Circular dependency found ${path}`); } export interface Scope { name: string; - instances: { [name: string]: any }; } export type ResolveToken = T extends ClassType ? R : T extends AbstractClassType ? R : T; @@ -149,7 +167,27 @@ export function resolveToken(provider: ProviderWithScope): Token { return provider.provide; } -export type ContainerToken = Exclude>; +/** @reflection never */ +export type ContainerToken = symbol | number | bigint | boolean | string | AbstractClassType | Function; + +export function getContainerTokenFromType(type: Type): ContainerToken { + if (type.id) return type.id; + if (type.kind === ReflectionKind.literal) { + if (type.literal instanceof RegExp) return type.literal.toString(); + return type.literal; + } + if (type.kind === ReflectionKind.class) return type.classType; + if (type.kind === ReflectionKind.function && type.function) return type.function; + return 'unknown'; +} + +export function isType(obj: any): obj is Type { + return obj && typeof obj === 'object' && typeof (obj as any).kind === 'number'; +} + +function isPacked(obj: any): obj is Packed { + return obj && typeof obj === 'object' && typeof (obj as any).length === 'number'; +} /** * Returns a value that can be compared with `===` to check if two tokens are actually equal even though @@ -161,21 +199,10 @@ export function getContainerToken(type: Token): ContainerToken { if (type instanceof TagProvider) return getContainerToken(type.provider.provide); if (isType(type)) { - if (type.id) return type.id; - if (type.kind === ReflectionKind.literal) { - if (type.literal instanceof RegExp) return type.literal.toString(); - return type.literal; - } - if (type.kind === ReflectionKind.class) return type.classType; - if (type.kind === ReflectionKind.function && type.function) return type.function; - throw new Error(`Could not resolve token ${stringifyType(type)}`); + return getContainerTokenFromType(type); } - return type; -} - -export interface InjectorInterface { - get(token: T, scope?: Scope): ResolveToken; + return type as ContainerToken; } /** @@ -215,30 +242,96 @@ export class TransientInjectionTarget { } } +function* forEachDependency(provider: NormalizedProvider): Generator<{ type: Type, optional: boolean }> { + if (isValueProvider(provider)) { + } else if (isClassProvider(provider)) { + let useClass = provider.useClass; + if (!useClass) { + if (isClass(provider.provide)) useClass = provider.provide as ClassType; + if (isType(provider.provide) && provider.provide.kind === ReflectionKind.class) useClass = provider.provide.classType; + if (!useClass) { + throw new Error(`UseClassProvider needs to set either 'useClass' or 'provide' as a ClassType. Got ${provider.provide as any}`); + } + } + + const reflectionClass = ReflectionClass.from(useClass); + + const constructor = reflectionClass.getMethodOrUndefined('constructor'); + if (constructor) { + for (const parameter of constructor.getParameters()) { + const tokenType = getInjectOptions(parameter.getType() as Type); + const type = tokenType || parameter.getType() as Type; + yield { type, optional: !parameter.isValueRequired() }; + } + } + + for (const property of reflectionClass.getProperties()) { + const tokenType = getInjectOptions(property.type); + if (!tokenType) continue; + yield { type: tokenType, optional: !property.isValueRequired() }; + } + } else if (isExistingProvider(provider)) { + for (const item of forEachDependency({ provide: provider.useExisting })) { + yield item; + } + } else if (isFactoryProvider(provider)) { + const reflection = ReflectionFunction.from(provider.useFactory); + for (const parameter of reflection.getParameters()) { + const tokenType = getInjectOptions(parameter.getType() as Type); + const type = tokenType || parameter.getType() as Type; + yield { type, optional: !parameter.isValueRequired() }; + } + } +} + +function isTransientInjectionTargetProvider(prepared: PreparedProvider): boolean { + for (const provider of prepared.providers) { + if (!provider.transient) continue; + for (const { type } of forEachDependency(provider)) { + if (type.kind === ReflectionKind.class && type.classType === TransientInjectionTarget) { + return true; + } + } + } + return false; +} + /** * A factory function for some class. * All properties that are not provided will be resolved using the injector that was used to create the factory. */ export type PartialFactory = (args: Partial<{ [K in keyof C]: C[K] }>) => C; +interface BuiltInjector { + set(token: Token, value: any, scope?: Scope): void; + + get(token: Token, scope?: Scope, optional?: boolean): unknown; + + instantiationCount(token: any, scope?: Scope): number; + + clear(): void; + + resolver(token: Token, scope?: Scope, optional?: boolean): Resolver; + + setter(token: Token, scope?: Scope): Setter; + + collectStack(stack: StackEntry[]): void; +} + +type BuiltNormalizedProvider = NormalizedProvider & { needsDestination?: boolean }; +type BuiltPreparedProvider = PreparedProvider & { built?: { resolver: string, name: string, label: string } }; + /** * This is the actual dependency injection container. * Every module has its own injector. * * @reflection never */ -export class Injector implements InjectorInterface { - private resolver?: (token: any, scope?: Scope, destination?: Destination, scopes?: string[], optional?: boolean) => any; - private setter?: (token: any, value: any, scope?: Scope) => any; - private instantiations?: (token: any, scope?: string) => number; +export class Injector { + private built?: BuiltInjector; - /** - * All unscoped provider instances. Scoped instances are attached to `Scope`. - */ - private instances: { [name: string]: any } = {}; - private instantiated: { [name: string]: number } = {}; - - private resolverMap = new Map>; + private resolverMap = new Map>; + private setterMap = new Map>; constructor( public readonly module: InjectorModule, @@ -252,178 +345,494 @@ export class Injector implements InjectorInterface { return new Injector(new InjectorModule(providers, parent?.module), new BuildContext); } - static fromModule(module: InjectorModule, parent?: Injector): Injector { + static fromModule(module: InjectorModule): Injector { return new Injector(module, new BuildContext); } - get(token?: ReceiveType | Token, scope?: Scope): ResolveToken { - if (!this.resolver) throw new Error('Injector was not built'); - if (!token) throw new Error('Token is required'); - return this.getResolver(token as ReceiveType | Token)(scope) as ResolveToken; + get(token?: ReceiveType | Token, scope?: Scope, optional: boolean = false): ResolveToken { + if (!this.built) throw new Error('Injector was not built'); + token = isPacked(token) ? resolveReceiveType(token) : token; + return this.built.get(token, scope, optional) as ResolveToken; } - set(token: ContainerToken, value: any, scope?: Scope): void { - if (!this.setter) throw new Error('Injector was not built'); - this.setter(token, value, scope); + set(token: Token, value: any, scope?: Scope): void { + if (!this.built) throw new Error('Injector was not built'); + this.built.set(token, value, scope); } - instantiationCount(token: any, scope?: string): number { - if (!this.instantiations) throw new Error('Injector was not built'); - return this.instantiations(token, scope); + instantiationCount(token: any, scope?: Scope): number { + if (!this.built) throw new Error('Injector was not built'); + return this.built.instantiationCount(token, scope); + } + + getSetter(token: ReceiveType | Token): Setter { + let setter = this.setterMap.get(token); + if (!setter) { + setter = this.createSetter(token as ReceiveType | Token); + this.setterMap.set(token, setter); + } + return setter; + } + + getResolver(token?: ReceiveType | Token, label?: string): Resolver { + if (!token) throw new Error('No token provided'); + let resolver = this.resolverMap.get(token); + if (!resolver) { + resolver = this.createResolver(token as ReceiveType | Token, undefined, label); + this.resolverMap.set(token, resolver); + } + return resolver as Resolver; } clear() { - this.instances = {}; + this.built?.clear(); } protected build(buildContext: BuildContext): void { - const resolverCompiler = new CompilerContext(); - resolverCompiler.context.set('CircularDetector', CircularDetector); - resolverCompiler.context.set('CircularDetectorResets', CircularDetectorResets); - resolverCompiler.context.set('throwCircularDependency', throwCircularDependency); - resolverCompiler.context.set('knownServiceNotfoundError', knownServiceNotfoundError); - resolverCompiler.context.set('tokenLabel', tokenLabel); - resolverCompiler.context.set('constructorParameterNotFound', constructorParameterNotFound); - resolverCompiler.context.set('functionParameterNotFound', functionParameterNotFound); - resolverCompiler.context.set('propertyParameterNotFound', propertyParameterNotFound); - resolverCompiler.context.set('factoryDependencyNotFound', factoryDependencyNotFound); - resolverCompiler.context.set('transientInjectionTargetUnavailable', transientInjectionTargetUnavailable); - resolverCompiler.context.set('createTransientInjectionTarget', createTransientInjectionTarget); - resolverCompiler.context.set('injector', this); - - const lines: string[] = []; - const resets: string[] = []; - const creating: string[] = []; - - const instantiationCompiler = new CompilerContext(); - instantiationCompiler.context.set('injector', this); - const instantiationLines: string[] = []; - - const setterCompiler = new CompilerContext(); - setterCompiler.context.set('injector', this); - const setterLines: string[] = []; - - for (const prepared of this.module.getPreparedProviders(buildContext)) { - //scopes will be created first, so they are returned instead of the unscoped instance + const compiler = new CompilerContext(); + compiler.set({ + throwCircularDependency, + knownServiceNotFoundInScope, + knownServiceNotFoundError, + serviceNotFoundError, + tokenLabel, + constructorParameterNotFound, + functionParameterNotFound, + propertyParameterNotFound, + factoryDependencyNotFound, + transientInjectionTargetUnavailable, + createTransientInjectionTarget, + getContainerToken, + stackToPath, + 'injector': this, + 'runtimeContext': buildContext.runtimeContext, + createResolver: (token: Token, scope?: Scope, label?: string) => { + return this.createResolver(token, scope, label); + }, + }); + + const functions: string[] = []; + const init: string[] = []; + const reset: string[] = []; + const collect: string[] = []; + const clear: string[] = []; + const setReferences: string[] = []; + + const preparedProviders = this.module.getPreparedProviders(buildContext) as BuiltPreparedProvider[]; + for (const prepared of preparedProviders) { + const i = this.buildContext.providerIndex.reserve(); + const label = tokenLabel(prepared.token); + const name = 'i' + i + '_' + label.replace(/[^a-zA-Z0-9]/g, '_'); + prepared.built = { + name, + label, + resolver: `resolve${name}`, + }; + } + + for (const prepared of preparedProviders) { + if (!prepared.built) continue; + const name = prepared.built.name; + const label = prepared.built.label; + + // scopes will be created first, so they are returned instead of the unscoped instance prepared.providers.sort((a, b) => { if (a.scope && !b.scope) return -1; if (!a.scope && b.scope) return +1; return 0; }); - for (const provider of prepared.providers) { - const scope = getScope(provider); - const name = 'i' + this.buildContext.providerIndex.reserve(); - creating.push(`let creating_${name} = false;`); - resets.push(`creating_${name} = false;`); - const accessor = scope ? 'scope.instances.' + name : 'injector.instances.' + name; - let scopeObjectCheck = scope ? ` && scope && scope.name === ${JSON.stringify(scope)}` : ''; - const scopeCheck = scope ? ` && scope === ${JSON.stringify(scope)}` : ''; + // console.log(`${label} got ${prepared.providers.length} providers for module ${getClassName(this.module)}. ` + + // `scopes ${prepared.providers.map(v => v.scope).join(', ')}. ` + + // `resolveFrom=${prepared.resolveFrom ? getClassName(prepared.resolveFrom) : 'none'}. ` + + // `resolve from modules ${prepared.modules.map(getClassName).join(', ')}. `); - const diToken = getContainerToken(prepared.token); + const containerToken = getContainerToken(prepared.token); + const containerTokenVar = compiler.reserveVariable('containerToken', containerToken); - setterLines.push(`case token === ${setterCompiler.reserveVariable('token', diToken)}${scopeObjectCheck}: { - if (${accessor} === undefined) { - injector.instantiated.${name} = injector.instantiated.${name} ? injector.instantiated.${name} + 1 : 1; - } - ${accessor} = value; - break; - }`); + const factoryNames: { + scope: string, + function: string, + }[] = []; - if (prepared.resolveFrom) { - //it's a redirect - lines.push(` - case token === ${resolverCompiler.reserveConst(diToken, 'token')}${scopeObjectCheck}: { - return ${resolverCompiler.reserveConst(prepared.resolveFrom, 'resolveFrom')}.injector.resolver(${resolverCompiler.reserveConst(diToken, 'token')}, scope, destination, scopes); - } - `); + const setterNames: { + scope: string, + function: string, + }[] = []; + + const instantiationsNames: { + scope: string, + function: string, + }[] = []; - instantiationLines.push(` - case token === ${instantiationCompiler.reserveConst(diToken, 'token')}${scopeCheck}: { - return ${instantiationCompiler.reserveConst(prepared.resolveFrom, 'resolveFrom')}.injector.instantiations(${instantiationCompiler.reserveConst(diToken, 'token')}, scope); + if (prepared.resolveFrom) { + const factory = `factory${name}`; + const setter = `setter${name}`; + const instantiations = `instantiations${name}`; + + const injectorVar = compiler.reserveConst(prepared.resolveFrom!.injector, 'injector'); + + functions.push(` + function ${factory}(scope, optional) { + return ${injectorVar}.built.get(${containerTokenVar}, scope, optional); } - `); - } else { - //we own and instantiate the service - instantiationLines.push(` - case token === ${instantiationCompiler.reserveConst(diToken, 'token')}${scopeCheck}: { - return injector.instantiated.${name} || 0; + + function ${setter}(value, scope) { + ${injectorVar}.built.set(${containerTokenVar}, value, scope); } + + function ${instantiations}(scope) { + return ${injectorVar}.built.instantiationCount(${containerTokenVar}, scope); + } + `); + + if (isClass(containerToken)) { + setReferences.push(` + ${containerTokenVar}[symbolResolver] = () => ${factory}; + ${containerTokenVar}[symbolSetter] = () => ${setter}; + ${containerTokenVar}[symbolInstantiations] = () => ${instantiations}; `); + } - lines.push(this.buildProvider(buildContext, resolverCompiler, name, accessor, scope, prepared.token, provider, prepared.modules)); + if (isType(prepared.token) && prepared.token.kind === ReflectionKind.class) { + const classTypeVar = compiler.reserveVariable('classType', prepared.token.classType); + setReferences.push(` + ${classTypeVar}[symbolResolver] = () => ${factory}; + ${classTypeVar}[symbolSetter] = () => ${setter}; + ${classTypeVar}[symbolInstantiations] = () => ${instantiations}; + `); } - } - } - this.instantiations = instantiationCompiler.build(` - //for ${getClassName(this.module)} - switch (true) { - ${instantiationLines.join('\n')} - } - return 0; - `, 'token', 'scope'); + setReferences.push(` + lookupGetter[${containerTokenVar}] = () => ${factory}; + lookupSetter[${containerTokenVar}] = () => ${setter}; + lookupInstantiations[${containerTokenVar}] = () => ${instantiations}; + `); + } else { + const scopeNames = JSON.stringify(prepared.providers.map(v => v.scope).filter(v => !!v)); + + for (const provider of prepared.providers) { + const scope = getScope(provider); + const container = scope ? 'scope' : 'instances'; + const varName = `${container}.${name}`; + const state = 's' + prepared.built.name + (scope ? '_' + scope : ''); + const check = scope ? `if (!scope || scope.name !== ${JSON.stringify(scope)}) return optional ? undefined : knownServiceNotFoundInScope(${JSON.stringify(label)}, ${scopeNames}, scope);` : ''; + + const scopeAffix = scope ? '_' + scope : '_global'; + const factory = `factory${name}${scopeAffix}`; + const setter = `setter${name}${scopeAffix}`; + const instantiations = `instantiations${name}${scopeAffix}`; + + const code = this.createFactoryCode(buildContext, compiler, varName, prepared.token, provider, prepared.modules); + + init.push(` + const ${state} = { + label: ${JSON.stringify(label)}, + count: 0, + creating: 0, + cause: false, + };`); + + factoryNames.push({ scope, function: factory }); + setterNames.push({ scope, function: setter }); + instantiationsNames.push({ scope, function: instantiations }); + + reset.push(`${state}.creating = 0; ${state}.cause = false;`); + collect.push(`if (${state}.creating) stack.push(${state});`); + clear.push(`${varName} = undefined;`); + + let setDestination = ``; + if (code.needsDestination) { + const tokenVar = compiler.reserveConst(prepared.token, 'token'); + setDestination = `runtimeContext.destination = { token: ${tokenVar} };`; + } - this.setter = setterCompiler.build(` - //for ${getClassName(this.module)} - switch (true) { - ${setterLines.join('\n')} - } - `, 'token', 'value', 'scope'); + let circularCheckBefore = ''; + let circularCheckAfter = ''; + if (code.dependencies) { + circularCheckBefore = ` + if (${state}.creating > 0) { + ${state}.cause = true; + const stack = []; + collectStack(stack); + const paths = stackToPath(stack); + reset(); + throwCircularDependency(paths); + } + ${state}.creating = ++runtimeContext.creation; + `; + circularCheckAfter = `${state}.creating = 0;`; + } - this.resolver = resolverCompiler.raw(` - //for ${getClassName(this.module)} - - ${creating.join('\n')}; + let returnExisting = ``; + if (!provider.transient) { + returnExisting = `if (${varName}) return ${varName};`; + } - CircularDetectorResets.push(() => { - ${resets.join('\n')}; - }); + functions.push(` + //${label}, from ${prepared.modules.map(getClassName).join(', ')} + function ${factory}(scope, optional) { + ${check} + ${returnExisting} + ${circularCheckBefore} + ${setDestination} + try { + ${code.code} + ${state}.count++; + } finally { + ${state}.creating = 0; + } + ${circularCheckAfter} + if (!${varName} && !optional) knownServiceNotFoundError(${JSON.stringify(label)}, ${scopeNames}, scope); + return ${varName}; + } - return function(token, scope, destination, scopes, optional) { - scopes = scopes || []; + function ${setter}(value, scope) { + ${check} + ${varName} = value; + } + + function ${instantiations}(scope) { + ${check} + return ${state}.count; + } + `); + } - switch (true) { - ${lines.join('\n')} + if (factoryNames.length > 1) { + // we need to override lookup/symbol for the scope + // and add a router to correctly route scopes to the function + const routerName = `router${name}`; + // prepared.factory = `factory_${routerName}`; + + functions.push(` + function ${routerName}_factory(scope, optional) { + const name = scope?.name || ''; + switch (name) { + ${factoryNames.map(v => `case ${JSON.stringify(v.scope)}: return ${v.function}(scope, optional);`).join('\n')} + // default: knownServiceNotFoundInScope(${JSON.stringify(label)}, ${scopeNames}, scope); + default: { + if (optional) return undefined; + knownServiceNotFoundInScope(${JSON.stringify(label)}, ${scopeNames}, scope); + } + } + } + + // scope router for ${factoryNames.map(v => v.scope).join(', ')} + function ${prepared.built.resolver}(scope, optional) { + const name = scope?.name || ''; + switch (name) { + ${factoryNames.map(v => `case ${JSON.stringify(v.scope)}: return ${v.function};`).join('\n')} + default: return ${routerName}_factory; // no scope given, so return route for value itself + } + } + + function ${routerName}_setter(value, scope, optional) { + const name = scope?.name || ''; + switch (name) { + ${setterNames.map(v => `case ${JSON.stringify(v.scope)}: return ${v.function}(value, scope, optional);`).join('\n')} + // default: knownServiceNotFoundInScope(${JSON.stringify(label)}, ${scopeNames}, scope); + default: { + if (optional) return undefined; + knownServiceNotFoundInScope(${JSON.stringify(label)}, ${scopeNames}, scope); + } + } + } + + function setter_${routerName}(scope) { + const name = scope?.name || ''; + switch (name) { + ${setterNames.map(v => `case ${JSON.stringify(v.scope)}: return ${v.function};`).join('\n')} + default: return ${routerName}_setter; // no scope given, so return route for value itself + } + } + + function instantiations_${routerName}(scope) { + const name = scope?.name || ''; + switch (name) { + ${instantiationsNames.map(v => `case ${JSON.stringify(v.scope)}: return ${v.function};`).join('\n')} + default: knownServiceNotFoundInScope(${JSON.stringify(label)}, ${scopeNames}, scope); + } } + `); + + if (isClass(containerToken)) { + setReferences.push(` + ${containerTokenVar}[symbolResolver] = ${prepared.built.resolver}; + ${containerTokenVar}[symbolSetter] = setter_${routerName}; + ${containerTokenVar}[symbolInstantiations] = instantiations_${routerName}; + `); + } + + if (isType(prepared.token) && prepared.token.kind === ReflectionKind.class) { + const classTypeVar = compiler.reserveVariable('classType', prepared.token.classType); + setReferences.push(` + ${classTypeVar}[symbolResolver] = ${prepared.built.resolver}; + ${classTypeVar}[symbolSetter] = setter_${routerName}; + ${classTypeVar}[symbolInstantiations] = instantiations_${routerName}; + `); + } + + setReferences.push(` + lookupGetter[${containerTokenVar}] = ${prepared.built.resolver}; + lookupSetter[${containerTokenVar}] = setter_${routerName}; + lookupInstantiations[${containerTokenVar}] = instantiations_${routerName}; + `); + } else if (factoryNames.length === 1) { + const factory = factoryNames[0].function; + const setter = setterNames[0].function; + const instantiations = instantiationsNames[0].function; + + functions.push(` + const ${prepared.built.resolver} = () => ${factory}; + `); + + if (isClass(containerToken)) { + setReferences.push(` + ${containerTokenVar}[symbolResolver] = ${prepared.built.resolver}; + ${containerTokenVar}[symbolSetter] = () => ${setter}; + ${containerTokenVar}[symbolInstantiations] = () => ${instantiations}; + `); + } + + if (isType(prepared.token) && prepared.token.kind === ReflectionKind.class) { + const classTypeVar = compiler.reserveVariable('classType', prepared.token.classType); + setReferences.push(` + ${classTypeVar}[symbolResolver] = ${prepared.built.resolver}; + ${classTypeVar}[symbolSetter] = () => ${setter}; + ${classTypeVar}[symbolInstantiations] = () => ${instantiations}; + `); + } - if (!optional) knownServiceNotfoundError(tokenLabel(token), scopes, scope); + setReferences.push(` + lookupGetter[${containerTokenVar}] = ${prepared.built.resolver}; + lookupSetter[${containerTokenVar}] = () => ${setter}; + lookupInstantiations[${containerTokenVar}] = () => ${instantiations}; + `); + } } - `) as any; + } + + // console.log(`built injector for ${getClassName(this.module)}`); + + this.built = compiler.raw(` + //for ${getClassName(this.module)} + + const instances = {}; + const state = { + faulty: 0, + creating: 1 + }; + const lookupGetter = {}; + const lookupSetter = {}; + const lookupInstantiations = {}; + + const symbolResolver = Symbol('resolver'); + const symbolSetter = Symbol('setter'); + const symbolInstantiations = Symbol('instantiations'); + + // collectStack(stack: { label: string, id: number }[]): void + function collectStack(stack) { + ${collect.join('\n')} + ${this.module.imports.map(v => `${compiler.reserveConst(v)}.injector.built.collectStack(stack);`).join('\n')} + } + + function reset() { + runtimeContext.creation = 0; + ${reset.join('\n')} + } + + function noop() {} + + ${init.join('\n')} + + ${functions.join('\n')} + + ${setReferences.join('\n')} + + // resolver(token: Token, scope?: Scope, optional?: boolean): Resolver; + function resolver(token, scope, optional) { + // token could be: Type, ClassType, or primitive + const containerToken = getContainerToken(token); + const fn = containerToken[symbolResolver] || lookupGetter[containerToken]; + if (fn) return fn(scope); + + const resolver = createResolver(token, scope); + if (resolver) { + lookupSetter[containerToken] = resolver; + return resolver; + } + + if (optional) return noop; + throw serviceNotFoundError(tokenLabel(token)); + } + + // setter(token: Token, scope?: Scope): Setter; + function setter(token, scope) { + const containerToken = getContainerToken(token); + const fn = containerToken[symbolSetter] || lookupSetter[containerToken]; + if (fn) return fn(scope); + throw serviceNotFoundError(tokenLabel(token)); + } + + // set(token: Token, value: any, scope?: Scope): void; + function set(token, value, scope) { + setter(token)(value, scope); + } + + // get(token: Token, scope?: Scope, optional?: boolean): unknown; + function get(token, scope, optional) { + return resolver(token, scope)(scope, optional); + } + + // clear(): void; + function clear() { + ${clear.join('\n')} + } + + // instantiationCount(token: any, scope?: string): number; + function instantiationCount(token, scope) { + const containerToken = getContainerToken(token); + const fn = lookupInstantiations[containerToken]; + if (fn) return fn(scope)(scope); + return 0; + } + + return { resolver, setter, get, set, clear, instantiationCount, collectStack }; + `) as any; } - protected buildProvider( + protected createFactoryCode( buildContext: BuildContext, compiler: CompilerContext, - name: string, - accessor: string, - scope: string, + varName: string, token: Token, - provider: NormalizedProvider, + provider: BuiltNormalizedProvider, resolveDependenciesFrom: InjectorModule[], ) { let transient = false; let factory: { code: string, dependencies: number } = { code: '', dependencies: 0 }; - const tokenVar = compiler.reserveConst(getContainerToken(token)); if (isValueProvider(provider)) { transient = provider.transient === true; const valueVar = compiler.reserveVariable('useValue', provider.useValue); - factory.code = `${accessor} = ${valueVar};`; + factory.code = `${varName} = ${valueVar};`; } else if (isClassProvider(provider)) { transient = provider.transient === true; let useClass = provider.useClass; if (!useClass) { - if (!isClass(provider.provide)) { + if (isClass(provider.provide)) useClass = provider.provide as ClassType; + if (isType(provider.provide) && provider.provide.kind === ReflectionKind.class) useClass = provider.provide.classType; + if (!useClass) { throw new Error(`UseClassProvider needs to set either 'useClass' or 'provide' as a ClassType. Got ${provider.provide as any}`); } - useClass = provider.provide as ClassType; } - factory = this.createFactory(provider, accessor, compiler, useClass, resolveDependenciesFrom); + factory = this.createFactory(provider, varName, compiler, useClass, resolveDependenciesFrom); } else if (isExistingProvider(provider)) { transient = provider.transient === true; - factory.code = `${accessor} = injector.resolver(${compiler.reserveConst(getContainerToken(provider.useExisting))}, scope, destination)`; + const existingToken = compiler.reserveConst(getContainerToken(provider.useExisting)); + factory.code = `${varName} = injector.built.get(${existingToken}, scope, optional);`; } else if (isFactoryProvider(provider)) { transient = provider.transient === true; const args: string[] = []; @@ -440,7 +849,7 @@ export class Injector implements InjectorInterface { }, provider, compiler, resolveDependenciesFrom, ofName, args.length, 'factoryDependencyNotFound')); } - factory.code = `${accessor} = ${compiler.reserveVariable('factory', provider.useFactory)}(${args.join(', ')});`; + factory.code = `${varName} = ${compiler.reserveVariable('factory', provider.useFactory)}(${args.join(', ')});`; } else { throw new Error('Invalid provider'); } @@ -459,7 +868,7 @@ export class Injector implements InjectorInterface { }); for (const configure of configurations) { - const args: string[] = [accessor]; + const args: string[] = [varName]; const reflection = ReflectionFunction.from(configure.call); const ofName = reflection.name === 'anonymous' ? 'configureProvider' : reflection.name; @@ -474,7 +883,7 @@ export class Injector implements InjectorInterface { const call = `${compiler.reserveVariable('configure', configure.call)}(${args.join(', ')});`; if (configure.options.replace) { - configureProvider.push(`${accessor} = ${call}`); + configureProvider.push(`${varName} = ${call}`); } else { configureProvider.push(call); } @@ -484,43 +893,21 @@ export class Injector implements InjectorInterface { configureProvider.push('//no custom provider setup'); } - let scopeCheck = scope ? ` && scope && scope.name === ${JSON.stringify(scope)}` : ''; - - //circular dependencies can happen, when for example a service with InjectorContext injected manually instantiates a service. - //if that service references back to the first one, it will be a circular loop. So we track that with `creating` state. - const creatingVar = `creating_${name}`; - const circularDependencyCheckStart = factory.dependencies ? `if (${creatingVar}) throwCircularDependency();${creatingVar} = true;` : ''; - const circularDependencyCheckEnd = factory.dependencies ? `${creatingVar} = false;` : ''; - - if (scopeCheck) scopeCheck = `&& scopes.push(${JSON.stringify(scope)}) ${scopeCheck}`; - - return ` - //${tokenLabel(token)}, from ${resolveDependenciesFrom.map(getClassName).join(', ')} - case token === ${tokenVar}${scopeCheck}: { - ${!transient ? `if (${accessor} !== undefined) return ${accessor};` : ''} - CircularDetector.push(${tokenVar}); - ${circularDependencyCheckStart} - injector.instantiated.${name} = injector.instantiated.${name} ? injector.instantiated.${name} + 1 : 1; - try { - ${factory.code} - } finally { - ${circularDependencyCheckEnd} - CircularDetector.pop(); - } - if (${accessor} !== undefined) { - ${configureProvider.join('\n')} - return ${accessor}; - } - if (!optional) { - knownServiceNotfoundError(tokenLabel(token), scopes, scope); - } - return; + return { + transient, + dependencies: factory.dependencies, + needsDestination: !!provider.needsDestination, + code: ` + ${factory.code} + if (${varName} !== undefined) { + ${configureProvider.join('\n')} } - `; + `, + }; } protected createFactory( - provider: NormalizedProvider, + provider: BuiltNormalizedProvider, resolvedName: string, compiler: CompilerContext, classType: ClassType, @@ -572,7 +959,7 @@ export class Injector implements InjectorInterface { protected createFactoryProperty( options: { name: string, type: Type, optional: boolean }, - fromProvider: NormalizedProvider, + fromProvider: BuiltNormalizedProvider, compiler: CompilerContext, resolveDependenciesFrom: InjectorModule[], ofName: string, @@ -580,7 +967,6 @@ export class Injector implements InjectorInterface { notFoundFunction: string, ): string { let of = `${ofName}.${options.name}`; - const destinationVar = compiler.reserveConst({ token: fromProvider.provide }); if (options.type.kind === ReflectionKind.class) { const found = findModuleForConfig(options.type.classType, resolveDependenciesFrom); @@ -593,12 +979,25 @@ export class Injector implements InjectorInterface { if (fromProvider.transient === true) { const tokenVar = compiler.reserveVariable('token', options.type.classType); const orThrow = options.optional ? '' : `?? transientInjectionTargetUnavailable(${JSON.stringify(ofName)}, ${JSON.stringify(options.name)}, ${argPosition}, ${tokenVar})`; - return `createTransientInjectionTarget(destination) ${orThrow}`; + return `createTransientInjectionTarget(runtimeContext.destination) ${orThrow}`; } else { throw new Error(`Cannot inject ${TransientInjectionTarget.name} into ${JSON.stringify(ofName)}.${JSON.stringify(options.name)}, as ${JSON.stringify(ofName)} is not transient`); } } + if (options.type.kind === ReflectionKind.class && options.type.classType === InjectorModule) { + // the last entry is always the module that defined the provider (no matter if it was exported) + const module = resolveDependenciesFrom[resolveDependenciesFrom.length - 1]; + return compiler.reserveVariable('module', module); + } + + + if (options.type.kind === ReflectionKind.class && options.type.classType === Injector) { + // the last entry is always the module that defined the provider (no matter if it was exported) + const module = resolveDependenciesFrom[resolveDependenciesFrom.length - 1]; + return `${compiler.reserveVariable('module', module)}.injector`; + } + if (options.type.kind === ReflectionKind.class && options.type.classType === TagRegistry) { return compiler.reserveVariable('tagRegistry', this.buildContext.tagRegistry); } @@ -617,7 +1016,7 @@ export class Injector implements InjectorInterface { const entries = this.buildContext.tagRegistry.resolve(options.type.classType); const args: string[] = []; for (const entry of entries) { - args.push(`${compiler.reserveConst(entry.module)}.injector.resolver(${compiler.reserveConst(getContainerToken(entry.tagProvider.provider.provide))}, scope, ${destinationVar})`); + args.push(`${compiler.reserveConst(entry.module)}.injector.built.get(${compiler.reserveConst(getContainerToken(entry.tagProvider.provider.provide))}, scope)`); } return `new ${tokenVar}(${resolvedVar} || (${resolvedVar} = [${args.join(', ')}]))`; } @@ -682,7 +1081,7 @@ export class Injector implements InjectorInterface { } } - let foundPreparedProvider: PreparedProvider | undefined = undefined; + let foundPreparedProvider: BuiltPreparedProvider | undefined = undefined; for (const module of resolveDependenciesFrom) { foundPreparedProvider = module.getPreparedProvider(options.type, foundPreparedProvider); } @@ -691,18 +1090,23 @@ export class Injector implements InjectorInterface { //the provider was exported from another module, so we need to check if there is a more specific candidate foundPreparedProvider = this.module.getPreparedProvider(options.type, foundPreparedProvider); } + const fromScope = getScope(fromProvider); - if (!foundPreparedProvider) { - //go up parent hierarchy - let current: InjectorModule | undefined = this.module; - while (current && !foundPreparedProvider) { - foundPreparedProvider = current.getPreparedProvider(options.type, foundPreparedProvider); - current = current.parent; - } + function invalidMatch(foundPreparedProvider?: PreparedProvider): boolean { + if (!foundPreparedProvider) return true; + const allPossibleScopes = foundPreparedProvider.providers.map(getScope); + const unscoped = allPossibleScopes.includes('') && allPossibleScopes.length === 1; + return !unscoped && !allPossibleScopes.includes(fromScope); + } + + // go up parent hierarchy and find a match + let current: InjectorModule | undefined = this.module; + while (current && invalidMatch(foundPreparedProvider)) { + foundPreparedProvider = current.getPreparedProvider(options.type, foundPreparedProvider); + current = current.parent; } if (!foundPreparedProvider && options.optional) return 'undefined'; - const fromScope = getScope(fromProvider); if (!foundPreparedProvider) { if (argPosition >= 0) { @@ -720,11 +1124,10 @@ export class Injector implements InjectorInterface { } const tokenVar = compiler.reserveVariable('token', getContainerToken(foundPreparedProvider.token)); - const allPossibleScopes = foundPreparedProvider.providers.map(getScope); - const unscoped = allPossibleScopes.includes('') && allPossibleScopes.length === 1; const foundProviderLabel = foundPreparedProvider.providers.map(v => v.provide).map(tokenLabel).join(', '); - if (!unscoped && !allPossibleScopes.includes(fromScope)) { + if (invalidMatch(foundPreparedProvider)) { + const allPossibleScopes = foundPreparedProvider.providers.map(getScope); const t = stringifyType(options.type, { showFullDefinition: false }); throw new DependenciesUnmetError( `Dependency '${options.name}: ${t}' of ${of} can not be injected into ${fromScope ? 'scope ' + fromScope : 'no scope'}, ` + @@ -736,30 +1139,85 @@ export class Injector implements InjectorInterface { //in this case, if the dependency is not optional, we throw an error. const orThrow = options.optional ? '' : `?? ${notFoundFunction}(${JSON.stringify(ofName)}, ${JSON.stringify(options.name)}, ${argPosition}, ${tokenVar})`; + if (isTransientInjectionTargetProvider(foundPreparedProvider)) { + fromProvider.needsDestination = true; + } + + if (foundPreparedProvider.resolveFrom) { + const injectorVar = compiler.reserveConst(foundPreparedProvider.resolveFrom.injector, 'injector'); + return `${injectorVar}.built.get(${tokenVar}, scope, true) ${orThrow}`; + } + const resolveFromModule = foundPreparedProvider.resolveFrom || foundPreparedProvider.modules[0]; if (resolveFromModule === this.module) { - return `injector.resolver(${tokenVar}, scope, ${destinationVar}, undefined, true) ${orThrow}`; + if (foundPreparedProvider.built) { + return `${foundPreparedProvider.built.resolver}(scope, true)(scope, true) ${orThrow}`; + } + return `resolver(${tokenVar}, scope, true)(scope, true) ${orThrow}`; } - return `${compiler.reserveConst(resolveFromModule)}.injector.resolver(${tokenVar}, scope, ${destinationVar}, undefined, true) ${orThrow}`; + + // go through module injector + return `${compiler.reserveConst(resolveFromModule)}.injector.built.resolver(${tokenVar}, scope, true)(scope, true) ${orThrow}`; } - getResolver(token: ReceiveType | Token, label?: string): Resolver { - let resolver = this.resolverMap.get(token); - if (!resolver) { - resolver = this.createResolver(token as ReceiveType | Token, label); - this.resolverMap.set(token, resolver); + protected resolveType(type: Type): PreparedProvider | undefined { + const resolveDependenciesFrom = [this.module]; + + let foundPreparedProvider: PreparedProvider | undefined = undefined; + for (const module of resolveDependenciesFrom) { + foundPreparedProvider = module.getPreparedProvider(type, foundPreparedProvider); + } + + if (resolveDependenciesFrom[0] !== this.module) { + //the provider was exported from another module, so we need to check if there is a more specific candidate + foundPreparedProvider = this.module.getPreparedProvider(type, foundPreparedProvider); } - return resolver; + + if (!foundPreparedProvider) { + //go up parent hierarchy + let current: InjectorModule | undefined = this.module; + while (current && !foundPreparedProvider) { + foundPreparedProvider = current.getPreparedProvider(type, foundPreparedProvider); + current = current.parent; + } + } + + return foundPreparedProvider; + } + + createSetter(token: ReceiveType | Token, scope?: Scope, label?: string): Setter { + if (token instanceof TagProvider) token = token.provider.provide; + + // todo remove isClass since it's slow + let type: Type | undefined = isType(token) ? token : isArray(token) || isClass(token) ? resolveReceiveType(token) : undefined; + + if (!type) { + const containerToken = getContainerToken(token as Token); + return this.built!.setter(containerToken); + } + + const foundPreparedProvider = this.resolveType(type); + + if (!foundPreparedProvider) { + const t = stringifyType(type, { showFullDefinition: false }); + const message = label ? `${label}: ${t}` : t; + throw serviceNotFoundError(message); + } + + const containerToken = getContainerToken(foundPreparedProvider.token); + const resolveFromModule = foundPreparedProvider.resolveFrom || foundPreparedProvider.modules[0]; + return resolveFromModule.injector!.built!.setter(containerToken, scope); } - protected createResolver(token: ReceiveType | Token, label?: string): Resolver { + createResolver(token: ReceiveType | Token, scope?: Scope, label?: string): Resolver { if (token instanceof TagProvider) token = token.provider.provide; + // todo remove isClass since it's slow let type: Type | undefined = isType(token) ? token : isArray(token) || isClass(token) ? resolveReceiveType(token) : undefined; if (!type) { const containerToken = getContainerToken(token as Token); - return (scope?: Scope) => this.resolver!(containerToken, scope); + return this.built!.resolver(containerToken, scope) as any; } const resolveDependenciesFrom = [this.module]; @@ -778,11 +1236,11 @@ export class Injector implements InjectorInterface { if (found) return () => getPathValue(found.module.getConfig(), found.path); } - if (type.kind === ReflectionKind.class && type.classType === TagRegistry) return () => this.buildContext.tagRegistry; + if (type.kind === ReflectionKind.class && type.classType === TagRegistry) return (() => this.buildContext.tagRegistry) as any; if (type.kind === ReflectionKind.class) { for (const module of resolveDependenciesFrom) { - if (module instanceof type.classType) return () => module; + if (module instanceof type.classType) return (() => module) as any; } } @@ -790,7 +1248,7 @@ export class Injector implements InjectorInterface { const entries = this.buildContext.tagRegistry.resolve(type.classType); const args: any[] = []; for (const entry of entries) { - args.push(entry.module.injector!.resolver!(entry.tagProvider.provider.provide)); + args.push(entry.module.injector!.built!.resolver(entry.tagProvider.provider.provide, scope)); } return new type.classType(args); @@ -799,7 +1257,7 @@ export class Injector implements InjectorInterface { if (type.kind === ReflectionKind.function && type.typeName === 'PartialFactory') { const factoryType = type.typeArguments?.[0]; const factory = partialFactory(factoryType, this); - return (scopeIn?: Scope) => factory(scopeIn); + return ((scopeIn?: Scope) => factory(scopeIn)) as any } if (isWithAnnotations(type)) { @@ -823,7 +1281,7 @@ export class Injector implements InjectorInterface { } } - return () => pickedConfig; + return (() => pickedConfig) as any; } } } @@ -850,69 +1308,26 @@ export class Injector implements InjectorInterface { current = current.indexAccessOrigin.container; } - if (config !== undefined) return () => config; + if (config !== undefined) return (() => config) as any; } } - let foundPreparedProvider: PreparedProvider | undefined = undefined; - for (const module of resolveDependenciesFrom) { - foundPreparedProvider = module.getPreparedProvider(type, foundPreparedProvider); - } - - if (resolveDependenciesFrom[0] !== this.module) { - //the provider was exported from another module, so we need to check if there is a more specific candidate - foundPreparedProvider = this.module.getPreparedProvider(type, foundPreparedProvider); - } - + const foundPreparedProvider = this.resolveType(type); if (!foundPreparedProvider) { - //go up parent hierarchy - let current: InjectorModule | undefined = this.module; - while (current && !foundPreparedProvider) { - foundPreparedProvider = current.getPreparedProvider(type, foundPreparedProvider); - current = current.parent; - } - } + if (optional) return (() => undefined) as any; - const t = stringifyType(type, { showFullDefinition: false }); - - if (!foundPreparedProvider) { - if (optional) return () => undefined; - throw new ServiceNotFoundError( - `Service '${label ? label + ': ' : ''}${t}' not found. No matching provider.`, - ); + const t = stringifyType(type, { showFullDefinition: false }); + const message = label ? `${label}: ${t}` : t; + throw serviceNotFoundError(message); } - // const allPossibleScopes = foundPreparedProvider.providers.map(getScope); - // const unscoped = allPossibleScopes.includes('') && allPossibleScopes.length === 1; - // - // if (!unscoped && !allPossibleScopes.includes(fromScope)) { - // const t = stringifyType(type, { showFullDefinition: false }); - // throw new ServiceNotFoundError( - // `Service "${t}" can not be received from ${fromScope ? 'scope ' + fromScope : 'no scope'}, ` + - // `since it only exists in scope${allPossibleScopes.length === 1 ? '' : 's'} ${allPossibleScopes.join(', ')}.` - // ); - // } - - const resolveFromModule = foundPreparedProvider.resolveFrom || foundPreparedProvider.modules[0]; - const containerToken = getContainerToken(foundPreparedProvider.token); - const injectorResolver = resolveFromModule.injector!.resolver!; - - const scopes = foundPreparedProvider.providers.map(getScope); - const transient = foundPreparedProvider.providers.some(v => isTransient(v)); - - let instance: any = undefined; - - const resolve = (scope?: Scope, optional?: boolean) => { - return injectorResolver(containerToken, scope, undefined, undefined, optional); + const resolveFromModule = foundPreparedProvider.resolveFrom || foundPreparedProvider.modules[0]; + if (!resolveFromModule.injector?.built) { + throw new Error('Injector was not built'); } - if (scopes.length || transient) return resolve; - - return (scope?: Scope, optional?: boolean) => { - instance = instance || resolve(scope, optional); - return instance; - }; + return resolveFromModule.injector.built.resolver(containerToken, scope) as Resolver; } } @@ -926,7 +1341,12 @@ class BuildProviderIndex { export class BuildContext { static ids: number = 0; - public id: number = BuildContext.ids++; + id: number = BuildContext.ids++; + + // this is shared in all built injectors to track the instantiation stack + // for circular dependency detection. + runtimeContext = { creation: 0 }; + tagRegistry: TagRegistry = new TagRegistry; providerIndex: BuildProviderIndex = new BuildProviderIndex; @@ -937,7 +1357,12 @@ export class BuildContext { globalConfigurationProviderRegistry: ConfigurationProviderRegistry = new ConfigurationProviderRegistry; } -export type Resolver = (scope?: Scope, optional?: boolean) => T; +export interface Resolver { + (scope: Scope | undefined, optional: true): T | undefined; + (scope?: Scope, optional?: boolean): T; +} + +export type Setter = (value: T, scope?: Scope, optional?: boolean) => void; /** * A InjectorContext is responsible for taking a root InjectorModule and build all Injectors. @@ -947,8 +1372,7 @@ export type Resolver = (scope?: Scope, optional?: boolean) => T; export class InjectorContext { constructor( public rootModule: InjectorModule, - public readonly scope?: Scope, - protected buildContext: BuildContext = new BuildContext, + public scope?: Scope, ) { } @@ -960,13 +1384,17 @@ export class InjectorContext { return this.getInjector(module || this.rootModule).getResolver(type) as Resolver; } + setter(module?: InjectorModule, type?: ReceiveType | Token): Setter { + return this.getInjector(module || this.rootModule).getSetter(type) as Setter; + } + /** * Returns an instance of the given token or type from the injector associated with the specified module. * * If there is no provider for the token or the provider returns undefined, it returns undefined. */ getOrUndefined(token?: ReceiveType | Token, module?: InjectorModule): ResolveToken | undefined { - const injector = this.getInjector(module || this.rootModule); + const injector = (module || this.rootModule).getOrCreateInjector(); return injector.get(token, this.scope, true); } @@ -976,17 +1404,18 @@ export class InjectorContext { * If there is no provider for the token or the provider returns undefined, it throws an error. */ get(token?: ReceiveType | Token, module?: InjectorModule): ResolveToken { - const injector = this.getInjector(module || this.rootModule); - return injector.get(token, this.scope); + const injector = (module || this.rootModule).getOrCreateInjector(); + return injector.get(token, this.scope) as ResolveToken; } /** * Returns the instantiation count of the given token. * - * This is either 0 or 1 for normal providers, and >= 0 for transient providers. + * This is either 0 or 1 for normal providers, and >= 0 for transient or scoped providers. */ instantiationCount(token: Token, module?: InjectorModule, scope?: string): number { - return this.getInjector(module || this.rootModule).instantiationCount(token, this.scope ? this.scope.name : scope); + const injector = this.getInjector(module || this.rootModule); + return injector.instantiationCount(token, scope ? { name: scope } : this.scope); } /** @@ -996,30 +1425,24 @@ export class InjectorContext { * outside the injector container and need to be injected into services. */ set(token: T, value: any, module?: InjectorModule): void { - return this.getInjector(module || this.rootModule).set( - getContainerToken(token), - value, - this.scope, - ); + const injector = (module || this.rootModule).getOrCreateInjector(); + return injector.set(token, value, this.scope); } static forProviders(providers: ProviderWithScope[]) { return new InjectorContext(new InjectorModule(providers)); } - /** - * Returns the unscoped injector. Use `.get(T, Scope)` for resolving scoped token. - */ getInjector(module: InjectorModule): Injector { - return module.getOrCreateInjector(this.buildContext); + return module.getOrCreateInjector(); } getRootInjector(): Injector { return this.getInjector(this.rootModule); } - public createChildScope(scope: string): InjectorContext { - return new InjectorContext(this.rootModule, { name: scope, instances: {} }, this.buildContext); + createChildScope(scope: string): InjectorContext { + return new InjectorContext(this.rootModule, { name: scope }); } } diff --git a/packages/injector/src/module.ts b/packages/injector/src/module.ts index 755d0ceba..9f43f5d2a 100644 --- a/packages/injector/src/module.ts +++ b/packages/injector/src/module.ts @@ -487,9 +487,11 @@ export class InjectorModule { return this; } - getOrCreateInjector(buildContext: BuildContext): Injector { + getOrCreateInjector(buildContext?: BuildContext): Injector { if (this.injector) return this.injector; + buildContext ||= new BuildContext; + //notify everyone we know to prepare providers if (this.parent) this.parent.getPreparedProviders(buildContext); this.getPreparedProviders(buildContext); diff --git a/packages/injector/src/provider.ts b/packages/injector/src/provider.ts index 9db72fc25..b74ffd41d 100644 --- a/packages/injector/src/provider.ts +++ b/packages/injector/src/provider.ts @@ -21,26 +21,26 @@ export interface ProviderBase { /** @reflection never */ export interface ProviderScope { - scope?: 'module' | 'rpc' | 'http' | 'cli' | string; + scope?: 'rpc' | 'http' | 'cli' | string; } /** @reflection never */ export type Token = symbol | number | bigint | boolean | string | AbstractClassType | Type | TagProvider | Function | T; export function provide( - provider: - | (ProviderBase & ProviderScope & - ( - | { useValue: T } - | { useClass: ClassType } - | { useExisting: any } - | { useFactory: (...args: any[]) => T | undefined } - )) + provider?: + | (ProviderBase & ProviderScope & ( + | { useValue?: T } + | { useClass: ClassType } + | { useExisting: any } + | { useFactory: (...args: any[]) => T | undefined } + )) | ClassType | ((...args: any[]) => T) , type?: ReceiveType, ): NormalizedProvider { + if (!provider) return { provide: resolveReceiveType(type) }; if (isClass(provider)) return { provide: resolveReceiveType(type), useClass: provider }; if (isFunction(provider)) return { provide: resolveReceiveType(type), useFactory: provider }; return { ...provider, provide: resolveReceiveType(type) }; @@ -109,7 +109,7 @@ interface TagRegistryEntry { /** @reflection never */ export class TagRegistry { constructor( - public tags: TagRegistryEntry[] = [] + public tags: TagRegistryEntry[] = [], ) { } @@ -137,7 +137,7 @@ export class Tag = TagProvider> { _2!: () => TP; constructor( - public readonly services: T[] = [] + public readonly services: T[] = [], ) { } diff --git a/packages/injector/tests/injector.spec.ts b/packages/injector/tests/injector.spec.ts index 0582d3da2..7ab0201fe 100644 --- a/packages/injector/tests/injector.spec.ts +++ b/packages/injector/tests/injector.spec.ts @@ -9,7 +9,7 @@ import { TransientInjectionTarget, } from '../src/injector.js'; import { InjectorModule } from '../src/module.js'; -import { ReflectionClass, ReflectionKind } from '@deepkit/type'; +import { ReflectionClass, ReflectionKind, typeOf } from '@deepkit/type'; import { Inject } from '../src/types.js'; import { provide } from '../src/provider.js'; @@ -28,15 +28,35 @@ test('injector basics', () => { const injector = Injector.from([MyServer, Connection]); expect(injector.get(Connection)).toBeInstanceOf(Connection); expect(injector.get(MyServer)).toBeInstanceOf(MyServer); + expect(injector.get()).toBeInstanceOf(Connection); }); -test('type injection', () => { +test('useExisting 1', () => { class Service {} const injector = Injector.from([Service, { provide: 'token', useExisting: Service }]); + expect(injector.get(Service)).toBeInstanceOf(Service); expect(injector.get()).toBeInstanceOf(Service); expect(injector.get('token')).toBeInstanceOf(Service); }); +test('useExisting 2', () => { + class Service {} + class Service2 {} + const injector = Injector.from([Service, provide({useExisting: Service})]); + expect(injector.get(Service)).toBeInstanceOf(Service); + expect(injector.get()).toBeInstanceOf(Service); +}); + +test('useExisting 3', () => { + class Service {} + class Service2 {} + const injector = Injector.from([provide(), provide({useExisting: typeOf()})]); + expect(injector.get(Service)).toBeInstanceOf(Service); + expect(injector.get(Service2)).toBeInstanceOf(Service); + expect(injector.get()).toBeInstanceOf(Service); + expect(injector.get()).toBeInstanceOf(Service); +}); + test('missing dep', () => { class Connection { } @@ -481,6 +501,36 @@ test('injector overwrite provider', () => { } }); +declare var asd: any; + +test('invalid constructor 1', () => { + class Service {} + + class MyServer { + constructor() { + asd.asd = []; + } + } + + const injector = Injector.from([MyServer]); + expect(() => injector.get(MyServer)).toThrow(`asd is not defined`); + expect(() => injector.get(MyServer)).toThrow(`asd is not defined`); +}); + +test('invalid constructor 2', () => { + class Service {} + + class MyServer { + constructor(private service: Service) { + asd.asd = []; + } + } + + const injector = Injector.from([MyServer, Service]); + expect(() => injector.get(MyServer)).toThrow(`asd is not defined`); + expect(() => injector.get(MyServer)).toThrow(`asd is not defined`); +}); + test('injector direct circular dependency', () => { class MyServer { constructor(private myServer: MyServer) { @@ -944,7 +994,7 @@ test('PartialFactory', () => { } const injector = Injector.from([B]); - const factory = injector.get>(); + const factory = injector.getResolver>()(); const a = factory({ num: 5, diff --git a/packages/injector/tests/injector2.spec.ts b/packages/injector/tests/injector2.spec.ts index 7e76cc5ec..9d85d0d15 100644 --- a/packages/injector/tests/injector2.spec.ts +++ b/packages/injector/tests/injector2.spec.ts @@ -42,7 +42,7 @@ test('scoped provider', () => { const context = new InjectorContext(module1); const injector = context.getInjector(module1); - expect(() => injector.get(Service)).toThrow(`Service 'Service' is known but has no value. Available in scopes: http, requested scope is global`); + expect(() => injector.get(Service)).toThrow(`Service 'Service' is known but is not available in scope global. Available in scopes: http.`); expect(context.createChildScope('http').get(Service)).toBeInstanceOf(Service); @@ -89,7 +89,7 @@ test('undefined scoped provider', () => { expect(() => scope.get(Service)).toThrow(`Service 'Service' is known but has no value. Available in scopes: http, requested scope is http`); const resolver = scope.resolve(module1, Service); - expect(() => resolver()).toThrow(`Service 'Service' is known but has no value. Available in scopes: http, requested scope is global`); + expect(() => resolver()).toThrow(`Service 'Service' is known but is not available in scope global. Available in scopes: http`); expect(resolver(undefined, true)).toBe(undefined); const controllerOptional = scope.get(ControllerOptional); @@ -109,6 +109,14 @@ test('undefined scoped provider', () => { expect(controllerRequired).toBeInstanceOf(ControllerRequired); expect(controllerRequired.service).toBeInstanceOf(Service); + + const scope2 = context.createChildScope('http'); + const setter = context.setter(module1, Service); + setter(new Service(), scope2.scope); + + expectService = scope2.get(Service); + expect(expectService).toBeInstanceOf(Service); + }); test('scoped provider with dependency to unscoped', () => { @@ -119,7 +127,7 @@ test('scoped provider with dependency to unscoped', () => { const context = new InjectorContext(module1); const injector = context.getInjector(module1); - expect(() => injector.get(Service)).toThrow(`Service 'Service' is known but has no value. Available in scopes: http, requested scope is global`); + expect(() => injector.get(Service)).toThrow(`Service 'Service' is known but is not available in scope global. Available in scopes: http`); const scope = context.createChildScope('http'); expect(scope.get(Service)).toBeInstanceOf(Service); @@ -186,6 +194,7 @@ test('tags', () => { class CollectorManager { constructor(protected collectors: CollectorTag) { + debugger; } getCollectors(): Collector[] { @@ -445,7 +454,7 @@ test('scope merging', () => { expect(request2).toBeInstanceOf(Request); expect(request2.id).toBe(2); //last provider is used - const request3 = injector.createChildScope('unknown').get(Request); + const request3 = injector.get(Request); expect(request3).toBeInstanceOf(Request); expect(request3.id).toBe(-1); //unscoped } @@ -1198,16 +1207,16 @@ test('instantiatedCount scope', () => { const root = new InjectorModule([{ provide: Request, scope: 'rpc' }]).addImport(module1); const injector = new InjectorContext(root); - expect(injector.instantiationCount(Request, module1)).toBe(0); + expect(() => injector.instantiationCount(Request, module1)).toThrow(`Service 'Request' is known but is not available in scope global`) { - expect(injector.createChildScope('http').instantiationCount(Request, module1, 'http')).toBe(0); + expect(injector.createChildScope('http').instantiationCount(Request, module1)).toBe(0); injector.createChildScope('http').get(Request, module1); - expect(injector.instantiationCount(Request, module1)).toBe(0); + expect(injector.instantiationCount(Request, module1, 'http')).toBe(1); - expect(injector.createChildScope('http').instantiationCount(Request, module1, 'http')).toBe(1); + expect(injector.createChildScope('http').instantiationCount(Request, module1)).toBe(1); injector.createChildScope('http').get(Request, module1); - expect(injector.createChildScope('http').instantiationCount(Request, module1, 'http')).toBe(2); + expect(injector.createChildScope('http').instantiationCount(Request, module1)).toBe(2); injector.createChildScope('rpc').get(Request); expect(injector.instantiationCount(Request, undefined, 'http')).toBe(2); expect(injector.instantiationCount(Request, undefined, 'rpc')).toBe(1); @@ -1280,7 +1289,7 @@ test('exported scoped can be replaced for another scope', () => { const root = new InjectorModule().addImport(frameworkModule); const injector = new InjectorContext(root); - expect(() => injector.get(HttpRequest)).toThrow(`Service 'HttpRequest' is known but has no value`); + expect(() => injector.get(HttpRequest)).toThrow(`Service 'HttpRequest' is known but is not available in scope global`); const scopeHttp = injector.createChildScope('http'); const httpRequest1 = scopeHttp.get(HttpRequest); @@ -1777,3 +1786,27 @@ test('deep config index direct sub class access', () => { const database = injector.get(Database); expect(database.url).toBe('localhost'); }); + +test('optional forwarded to external module', () => { + class ScopedService { + } + + class Service { + constructor(public scoped?: ScopedService) { + } + } + + const httpModule = new InjectorModule([ + { provide: ScopedService, scope: 'http', useValue: undefined }, + { provide: Service, scope: 'http', useFactory: (scoped?: ScopedService) => new Service(scoped) }, + ]).addExport(ScopedService, Service); + + const frameworkModule = new InjectorModule().addImport(httpModule).addExport(httpModule); + const rootModule = new InjectorModule([]).addImport(frameworkModule); + + const injector = new InjectorContext(rootModule); + const scoped = injector.createChildScope('http'); + + const service = scoped.get(Service); + expect(service.scoped).toBe(undefined); +}); diff --git a/packages/injector/tests/injector3.spec.ts b/packages/injector/tests/injector3.spec.ts new file mode 100644 index 000000000..a5ac7b7ba --- /dev/null +++ b/packages/injector/tests/injector3.spec.ts @@ -0,0 +1,251 @@ +import { expect, test } from '@jest/globals'; +import { InjectorModule } from '../src/module.js'; +import { Injector, InjectorContext } from '../src/injector.js'; +import { provide } from '../src/provider.js'; + +test('class + scope support', () => { + class ServiceA { + } + + class ServiceB { + constructor(public serviceA: ServiceA) { + } + } + + class ScopedServiceC { + constructor(public serviceA: ServiceA) { + } + } + + const providers = [ + { provide: ServiceA }, + { provide: ServiceB }, + { provide: ScopedServiceC, scope: 'rpc' }, + ]; + + const module = new InjectorModule(providers); + const injector = new InjectorContext(module); + + const a = injector.get(ServiceA); + const b = injector.get(ServiceB); + + expect(a).toBeInstanceOf(ServiceA); + expect(b).toBeInstanceOf(ServiceB); + + const scope1 = injector.createChildScope('rpc'); + const c1 = scope1.get(ScopedServiceC); + expect(c1).toBeInstanceOf(ScopedServiceC); + + const resolvedScopedServiceC = injector.resolve(undefined, ScopedServiceC); + const scope = injector.createChildScope('rpc'); + const c2 = resolvedScopedServiceC(scope.scope); + expect(c2).toBeInstanceOf(ScopedServiceC); +}); + +test('type + scope support', () => { + class ServiceA { + } + + class ServiceB { + constructor(public serviceA: ServiceA) { + } + } + + class ScopedServiceC { + constructor(public scope: string) { + } + } + + const providers = [ + provide(ServiceA), + provide(ServiceB), + provide({ scope: 'rpc', useValue: new ScopedServiceC('rpc') }), + provide({ scope: 'http', useValue: new ScopedServiceC('http') }), + ]; + + const module = new InjectorModule(providers); + const injector = new InjectorContext(module); + + expect(injector.get(ServiceA)).toBeInstanceOf(ServiceA); + expect(injector.get(ServiceB)).toBeInstanceOf(ServiceB); + expect(injector.get()).toBeInstanceOf(ServiceA); + expect(injector.get()).toBeInstanceOf(ServiceB); + + const scope1 = injector.createChildScope('rpc'); + const c1 = scope1.get(ScopedServiceC); + expect(c1).toBeInstanceOf(ScopedServiceC); + expect(c1.scope).toBe('rpc'); + + const resolvedScopedServiceC = injector.resolve(undefined, ScopedServiceC); + { + const scope = injector.createChildScope('rpc'); + const c2 = resolvedScopedServiceC(scope.scope); + expect(c2).toBeInstanceOf(ScopedServiceC); + expect(c1.scope).toBe('rpc'); + } + { + const scope = injector.createChildScope('http'); + const c2 = resolvedScopedServiceC(scope.scope); + expect(c2).toBeInstanceOf(ScopedServiceC); + expect(c2.scope).toBe('http'); + } +}); + +test('exported provider', () => { + class ModuleA extends InjectorModule { + } + + class ModuleB extends InjectorModule { + } + + class ServiceA { + } + + class ServiceB { + constructor(public serviceA: ServiceA) { + } + } + + const moduleB = new ModuleB([ + { provide: ServiceB, scope: 'rpc' }, + ]).addExport(ServiceB); + + const moduleA = new ModuleA([ + ServiceA, + { provide: ServiceB, scope: 'rpc' }, + ]).addImport(moduleB); + + const injector = new InjectorContext(moduleA); + const a = injector.get(ServiceA); + expect(a).toBeInstanceOf(ServiceA); + + expect(() => injector.get(ServiceB)).toThrowError('Service \'ServiceB\' is known but is not available in scope global. Available in scopes: rpc'); + + const scope = injector.createChildScope('rpc'); + const b1 = scope.get(ServiceB); + expect(b1).toBeInstanceOf(ServiceB); + + const b2 = scope.get(ServiceB, moduleB); + expect(b2).toBeInstanceOf(ServiceB); +}); + +test('optional forwarded to external module', () => { + class ScopedService { + } + + class Service { + constructor(public scoped?: ScopedService) { + } + } + + const httpModule = new InjectorModule([ + { provide: ScopedService, scope: 'http', useValue: undefined }, + { provide: Service, scope: 'http', useFactory: (scoped?: ScopedService) => new Service(scoped) }, + ]).addExport(ScopedService, Service); + + const frameworkModule = new InjectorModule().addImport(httpModule).addExport(httpModule); + const rootModule = new InjectorModule([]).addImport(frameworkModule); + + const injector = new InjectorContext(rootModule); + const scoped = injector.createChildScope('http'); + + const service = scoped.get(Service); + expect(service.scoped).toBe(undefined); +}); + +test('scoped InjectorContext', () => { + class RpcInjectorContext extends InjectorContext { + } + + class HttpListener { + constructor(public injector: InjectorContext) { + } + } + + const httpModule = new InjectorModule([ + HttpListener, + ]); + + const frameworkModule = new InjectorModule([ + { provide: RpcInjectorContext, scope: 'rpc', useValue: undefined }, + ]) + .addImport(httpModule) + .addExport(RpcInjectorContext, httpModule); + + const rootModule = new InjectorModule([ + { provide: InjectorContext, useFactory: () => injector }, + ]).addImport(frameworkModule); + + const injector = new InjectorContext(rootModule); + + const service = injector.get(HttpListener, httpModule); + expect(service.injector.constructor).toBe(InjectorContext); +}); + +test('setter of unspecified scope', () => { + class HttpRequest { + } + + class FrameworkModule extends InjectorModule { + } + + const frameworkModule = new FrameworkModule([ + { provide: HttpRequest, scope: 'http', useValue: undefined }, + { provide: HttpRequest, scope: 'rpc', useValue: undefined }, + ]).addExport(HttpRequest); + + const rootModule = new InjectorModule([]).addImport(frameworkModule); + const injector = new InjectorContext(rootModule); + const setter = injector.setter(undefined, HttpRequest); + + const httpRequest = new HttpRequest(); + const scope = injector.createChildScope('http'); + setter(httpRequest, scope.scope); + + expect(scope.get(HttpRequest)).toBe(httpRequest); +}); + +test('inject module', () => { + class HttpRequest { + constructor(public module: InjectorModule) { + } + } + + class HttpListener { + constructor(public module: InjectorModule) { + } + } + + class HttpListener2 { + constructor(public injector: Injector) { + } + } + + class HttpModule extends InjectorModule { + } + + class FrameworkModule extends InjectorModule { + } + + const httpModule = new HttpModule([ + HttpListener, + HttpListener2, + ]).addExport(HttpListener, HttpListener2); + + const frameworkModule = new FrameworkModule([ + { provide: HttpRequest, scope: 'http' }, + ]).addImport(httpModule).addExport(httpModule, HttpRequest); + + const rootModule = new InjectorModule([]).addImport(frameworkModule); + const injector = new InjectorContext(rootModule); + + const httpListener = injector.get(HttpListener); + expect(httpListener.module == httpModule).toBe(true); + + const httpListener2 = injector.get(HttpListener2); + expect(httpListener2.injector == httpModule.injector).toBe(true); + + const scope = injector.createChildScope('http'); + const httpRequest = scope.get(HttpRequest); + expect(httpRequest.module == frameworkModule).toBe(true); +}); diff --git a/packages/injector/tests/nominal.spec.ts b/packages/injector/tests/nominal.spec.ts index 693775853..7effeaf86 100644 --- a/packages/injector/tests/nominal.spec.ts +++ b/packages/injector/tests/nominal.spec.ts @@ -34,11 +34,12 @@ test('nominal alias types are unique', () => { provide({ useValue: new Service('B') }), ]); const injector = new InjectorContext(rootModule); + expect(injector.get().name).toBe('A'); + expect(injector.get().name).toBe('B'); + const database = injector.get(Manager); expect(database.service.name).toBe('A'); - expect(injector.get().name).toBe('A'); - expect(injector.get().name).toBe('B'); }); test('child implementation not override better match', () => { From be01b7395336badef3f215dc5df7bfba0a8b4b22 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 18:33:11 +0200 Subject: [PATCH 06/10] feat(logger): add debug2 level + more control over scope log level this allows to set a log level for a particular scope, for example allows to enable debug for a particular scope (or disable debug for a particular scope) --- packages/logger/src/logger.ts | 33 +++++++++++++------ packages/logger/tests/logger.spec.ts | 48 ++++++++++++---------------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index 9e5897be5..8125b0bbb 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -22,6 +22,7 @@ export enum LoggerLevel { log, info, debug, + debug2, // very verbose debug output } declare var process: { @@ -197,6 +198,8 @@ export interface LoggerInterface { info(...message: any[]): void; debug(...message: any[]): void; + + debug2(...message: any[]): void; } export class Logger implements LoggerInterface { @@ -208,7 +211,7 @@ export class Logger implements LoggerInterface { */ level: LoggerLevel = LoggerLevel.info; - protected debugScopes = new Set(); + protected scopeLevels = new Map(); protected logData?: LogData; @@ -226,16 +229,20 @@ export class Logger implements LoggerInterface { * * This is useful to enable debug logs only for certain parts of your application. */ - enableDebugScope(name: string) { - this.debugScopes.add(name); + enableDebugScope(...names: string[]) { + for (const name of names) this.scopeLevels.set(name, LoggerLevel.debug); + } + + disableDebugScope(...names: string[]) { + for (const name of names) this.scopeLevels.set(name, LoggerLevel.none); } - disableDebugScope(name: string) { - this.debugScopes.delete(name); + unsetDebugScope(...names: string[]) { + for (const name of names) this.scopeLevels.delete(name); } - isDebugScopeEnabled(name: string): boolean { - return this.debugScopes.has(name); + isScopeEnabled(name: string): boolean { + return (this.scopeLevels.get(name) ?? this.level) > LoggerLevel.none; } /** @@ -282,7 +289,7 @@ export class Logger implements LoggerInterface { scope, scopes: self.scopes, logData: self.logData, - debugScopes: self.debugScopes, + scopeLevels: self.scopeLevels, colorFormatter: self.colorFormatter, removeColorFormatter: self.removeColorFormatter, }, Logger.prototype); @@ -329,7 +336,11 @@ export class Logger implements LoggerInterface { } is(level: LoggerLevel): boolean { - return level <= this.level || (level === LoggerLevel.debug && this.debugScopes.has(this.scope)); + const scopeCheck = this.scopeLevels.size > 0 && !!this.scope ? this.scopeLevels.get(this.scope) : undefined; + + return scopeCheck !== undefined + ? scopeCheck > LoggerLevel.none && level <= scopeCheck + : level <= this.level; } protected send(messages: any[], level: LoggerLevel, data: LogData = {}) { @@ -379,6 +390,10 @@ export class Logger implements LoggerInterface { debug(...message: any[]) { this.send(message, LoggerLevel.debug); } + + debug2(...message: any[]) { + this.send(message, LoggerLevel.debug2); + } } /** diff --git a/packages/logger/tests/logger.spec.ts b/packages/logger/tests/logger.spec.ts index 0df81cca3..fb8ce2216 100644 --- a/packages/logger/tests/logger.spec.ts +++ b/packages/logger/tests/logger.spec.ts @@ -137,32 +137,6 @@ test('scoped logger', () => { expect(provider.logger).toBeInstanceOf(Logger); } - { - class A { - constructor(public b: B) { - } - } - - class B { - constructor(public c: C, public target: TransientInjectionTarget) { - } - } - - class C { - constructor(public target: TransientInjectionTarget) { - } - } - - const injector = Injector.from([ - A, - { provide: B, transient: true }, - { provide: C, transient: true }, - ]); - const a = injector.get(A); - expect(a.b.c.target.token).toBe(B); - expect(a.b.target.token).toBe(A); - } - { class A { constructor(public b: C) { @@ -196,11 +170,29 @@ test('enableDebug', () => { logger.enableDebugScope('database'); logger.debug('test1'); - expect(logger.isDebugScopeEnabled('database')).toBe(true); + expect(logger.isScopeEnabled('database')).toBe(true); const scoped = logger.scoped('database'); scoped.debug('test2'); - expect(scoped.isDebugScopeEnabled('database')).toBe(true); + expect(scoped.isScopeEnabled('database')).toBe(true); expect(logger.memory.messageStrings).toEqual(['test2']); }); + +test('enableDebug2', () => { + const logger = new MemoryLogger(); + logger.level = LoggerLevel.debug; + + logger.disableDebugScope('database'); + logger.debug('test1'); + expect(logger.isScopeEnabled('database')).toBe(false); + + const scoped = logger.scoped('database'); + scoped.debug('test2'); + expect(scoped.isScopeEnabled('database')).toBe(false); + expect(logger.memory.messageStrings).toEqual(['test1']); + + logger.unsetDebugScope('database'); + scoped.debug('test3'); + expect(logger.memory.messageStrings).toEqual(['test1', 'test3']); +}); From cebd164671543e106dee4459e2741dfe12f00020 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 18:33:30 +0200 Subject: [PATCH 07/10] feat(core): export AsyncFunction --- packages/core/benchmarks/promise.ts | 25 +++++++++++++++++++++++++ packages/core/package.json | 1 + packages/core/src/core.ts | 5 +++-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 packages/core/benchmarks/promise.ts diff --git a/packages/core/benchmarks/promise.ts b/packages/core/benchmarks/promise.ts new file mode 100644 index 000000000..d672bfe74 --- /dev/null +++ b/packages/core/benchmarks/promise.ts @@ -0,0 +1,25 @@ +import { benchmark, run } from '@deepkit/bench'; +import { asyncOperation } from '../src/core.js'; + +const sab = new SharedArrayBuffer(1024); +const int32 = new Int32Array(sab); + +benchmark('new Promise', async function benchmarkAction() { + await new Promise(function promiseCallback(resolve) { + resolve(); + }); +}); + +benchmark('asyncOperation', async function benchmarkAction() { + await asyncOperation(function promiseCallback(resolve) { + resolve(); + }); +}); + +benchmark('Atomics.waitAsync', async function benchmarkAction() { + const promise = (Atomics as any).waitAsync(int32, 0, 0); + Atomics.notify(int32, 0); + await promise.value; +}); + +run(); diff --git a/packages/core/package.json b/packages/core/package.json index 0b2742cd5..17bdfddb6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "to-fast-properties": "^3.0.1" }, "devDependencies": { + "@deepkit/bench": "^1.0.3", "@types/dot-prop": "~4.2.0" }, "scripts": { diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 32e5dcfb1..2df7ab9dc 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -197,8 +197,8 @@ export function isFunction(obj: any): obj is Function { return false; } -const AsyncFunction = (async () => { -}).constructor; +export const AsyncFunction = (async () => { +}).constructor as { new(...args: string[]): Function }; /** * Returns true if given obj is a async function. @@ -526,6 +526,7 @@ export function appendObject(origin: { [k: string]: any }, extend: { [k: string] * ``` * * @public + * @reflection never */ export async function asyncOperation(executor: (resolve: (value: T) => void, reject: (error: any) => void) => void | Promise): Promise { try { From 3589e62f7d35b6462925758973fe54cb41aa8497 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 18:34:54 +0200 Subject: [PATCH 08/10] feat(bson): convert if-else branch to lookup also remove unnecessary sizer call and export more symbols --- packages/bson/index.ts | 2 + .../bson/src/bson-deserializer-templates.ts | 117 ++++++++++-------- packages/bson/src/bson-parser.ts | 61 ++++++--- packages/bson/src/bson-serializer.ts | 22 +--- packages/bson/tests/bson-parser.spec.ts | 25 +++- 5 files changed, 136 insertions(+), 91 deletions(-) diff --git a/packages/bson/index.ts b/packages/bson/index.ts index 3e14d6672..c4b41a5b2 100644 --- a/packages/bson/index.ts +++ b/packages/bson/index.ts @@ -11,6 +11,8 @@ export * from './src/model.js'; export * from './src/bson-parser.js'; export { BaseParser } from './src/bson-parser.js'; +export { seekElementSize } from './src/continuation.js'; +export { BSONType } from './src/utils.js'; export * from './src/bson-deserializer.js'; export * from './src/bson-serializer.js'; export * from './src/strings.js'; diff --git a/packages/bson/src/bson-deserializer-templates.ts b/packages/bson/src/bson-deserializer-templates.ts index a3f1fe262..3ca578e35 100644 --- a/packages/bson/src/bson-deserializer-templates.ts +++ b/packages/bson/src/bson-deserializer-templates.ts @@ -40,6 +40,7 @@ import { } from '@deepkit/type'; import { seekElementSize } from './continuation.js'; import { BSONType, digitByteSize, isSerializable } from './utils.js'; +import { BaseParser } from './bson-parser.js'; function getNameComparator(name: string): string { //todo: support utf8 names @@ -74,55 +75,49 @@ export function deserializeAny(type: Type, state: TemplateState) { `); } -export function deserializeNumber(type: Type, state: TemplateState) { - const readBigInt = type.kind === ReflectionKind.bigint ? `state.parser.parseBinaryBigInt()` : `Number(state.parser.parseBinaryBigInt())`; +const numberParsers = createParserLookup(() => 0, [ + [BSONType.INT, parser => parser.parseInt()], + [BSONType.NUMBER, parser => parser.parseNumber()], + [BSONType.LONG, parser => parser.parseLong()], + [BSONType.TIMESTAMP, parser => parser.parseLong()], + [BSONType.BOOLEAN, parser => parser.parseBoolean() ? 1 : 0], + [BSONType.BINARY, parser => Number(parser.parseBinaryBigInt())], + [BSONType.STRING, parser => Number(parser.parseString())], +]); +export function deserializeNumber(type: Type, state: TemplateState) { + state.setContext({ numberParsers }); state.addCode(` - if (state.elementType === ${BSONType.INT}) { - ${state.setter} = state.parser.parseInt(); - } else if (state.elementType === ${BSONType.NULL} || state.elementType === ${BSONType.UNDEFINED}) { - ${state.setter} = 0; - } else if (state.elementType === ${BSONType.NUMBER}) { - ${state.setter} = state.parser.parseNumber(); - } else if (state.elementType === ${BSONType.LONG} || state.elementType === ${BSONType.TIMESTAMP}) { - ${state.setter} = state.parser.parseLong(); - } else if (state.elementType === ${BSONType.BOOLEAN}) { - ${state.setter} = state.parser.parseBoolean() ? 1 : 0; - } else if (state.elementType === ${BSONType.BINARY}) { - ${state.setter} = ${readBigInt}; - } else if (state.elementType === ${BSONType.STRING}) { - ${state.setter} = Number(state.parser.parseString()); - if (isNaN(${state.setter})) { - ${throwInvalidBsonType(type, state)} - } - } else { + ${state.setter} = numberParsers[state.elementType](state.parser); + if (isNaN(${state.setter})) { ${throwInvalidBsonType(type, state)} } `); } +const bigIntParsers = createParserLookup(() => 0n, [ + [BSONType.INT, parser => BigInt(parser.parseInt())], + [BSONType.NUMBER, parser => BigInt(parser.parseNumber())], + [BSONType.LONG, parser => BigInt(parser.parseLong())], + [BSONType.TIMESTAMP, parser => BigInt(parser.parseLong())], + [BSONType.BOOLEAN, parser => BigInt(parser.parseBoolean() ? 1 : 0)], + [BSONType.BINARY, parser => parser.parseBinaryBigInt()], + [BSONType.STRING, parser => BigInt(parser.parseString())], +]); + export function deserializeBigInt(type: Type, state: TemplateState) { const binaryBigInt = binaryBigIntAnnotation.getFirst(type); - const parseBigInt = binaryBigInt === BinaryBigIntType.signed ? 'parseSignedBinaryBigInt' : 'parseBinaryBigInt'; + + state.setContext({ bigIntParsers }); + let lookup = 'bigIntParsers'; + if (binaryBigInt === BinaryBigIntType.signed) { + const customLookup = bigIntParsers.slice(); + customLookup[BSONType.BINARY] = parser => parser.parseSignedBinaryBigInt(); + lookup = state.setVariable('lookup', customLookup); + } state.addCode(` - if (state.elementType === ${BSONType.INT}) { - ${state.setter} = BigInt(state.parser.parseInt()); - } else if (state.elementType === ${BSONType.NULL} || state.elementType === ${BSONType.UNDEFINED}) { - ${state.setter} = 0n; - } else if (state.elementType === ${BSONType.NUMBER}) { - ${state.setter} = BigInt(state.parser.parseNumber()); - } else if (state.elementType === ${BSONType.LONG} || state.elementType === ${BSONType.TIMESTAMP}) { - ${state.setter} = BigInt(state.parser.parseLong()); - } else if (state.elementType === ${BSONType.BOOLEAN}) { - ${state.setter} = BigInt(state.parser.parseBoolean() ? 1 : 0); - } else if (state.elementType === ${BSONType.BINARY} && ${binaryBigInt} !== undefined) { - ${state.setter} = state.parser.${parseBigInt}(); - } else if (state.elementType === ${BSONType.STRING}) { - ${state.setter} = BigInt(state.parser.parseString()); - } else { - ${throwInvalidBsonType(type, state)} - } + ${state.setter} = ${lookup}[state.elementType](state.parser); `); } @@ -205,21 +200,36 @@ export function deserializeUndefined(type: Type, state: TemplateState) { `); } +type Parse = (parser: BaseParser) => any; + +function createParserLookup(defaultParse: Parse, parsers: [elementType: BSONType, fn: Parse][]): Parse[] { + const result = [ + defaultParse, defaultParse, defaultParse, defaultParse, defaultParse, + defaultParse, defaultParse, defaultParse, defaultParse, defaultParse, + defaultParse, defaultParse, defaultParse, defaultParse, defaultParse, + defaultParse, defaultParse, defaultParse, defaultParse, defaultParse, + ]; + for (const [index, parse] of parsers) { + result[index] = parse; + } + return result; +} + +const booleanParsers = createParserLookup(() => 0, [ + [BSONType.BOOLEAN, parser => parser.parseBoolean()], + [BSONType.NULL, parser => 0], + [BSONType.UNDEFINED, parser => 0], + [BSONType.INT, parser => !!parser.parseInt()], + [BSONType.NUMBER, parser => !!parser.parseNumber()], + [BSONType.LONG, parser => !!parser.parseLong()], + [BSONType.TIMESTAMP, parser => !!parser.parseLong()], + [BSONType.STRING, parser => !!Number(parser.parseString())], +]); + export function deserializeBoolean(type: Type, state: TemplateState) { + state.setContext({ booleanParsers }); state.addCode(` - if (state.elementType === ${BSONType.BOOLEAN}) { - ${state.setter} = state.parser.parseBoolean(); - } else if (state.elementType === ${BSONType.NULL} || state.elementType === ${BSONType.UNDEFINED}) { - ${state.setter} = false; - } else if (state.elementType === ${BSONType.INT}) { - ${state.setter} = state.parser.parseInt() ? true : false; - } else if (state.elementType === ${BSONType.NUMBER}) { - ${state.setter} = state.parser.parseNumber() ? true : false; - } else if (state.elementType === ${BSONType.LONG} || state.elementType === ${BSONType.TIMESTAMP}) { - ${state.setter} = state.parser.parseLong() ? true : false; - } else { - ${throwInvalidBsonType(type, state)} - } + ${state.setter} = booleanParsers[state.elementType](state.parser); `); } @@ -538,7 +548,10 @@ export function deserializeArray(type: TypeArray, state: TemplateState) { state.setContext({ digitByteSize }); state.addCode(` - if (state.elementType && state.elementType !== ${BSONType.ARRAY}) ${throwInvalidBsonType({ kind: ReflectionKind.array, type: elementType }, state)} + if (state.elementType && state.elementType !== ${BSONType.ARRAY}) ${throwInvalidBsonType({ + kind: ReflectionKind.array, + type: elementType, + }, state)} { var ${result} = []; const end = state.parser.eatUInt32() + state.parser.offset; diff --git a/packages/bson/src/bson-parser.ts b/packages/bson/src/bson-parser.ts index 40542c04a..5eb6ade6b 100644 --- a/packages/bson/src/bson-parser.ts +++ b/packages/bson/src/bson-parser.ts @@ -8,13 +8,7 @@ * You should have received a copy of the MIT License along with this program. */ -import { - BSON_BINARY_SUBTYPE_BYTE_ARRAY, - BSON_BINARY_SUBTYPE_UUID, - BSONType, - digitByteSize, - TWO_PWR_32_DBL_N, -} from './utils.js'; +import { BSON_BINARY_SUBTYPE_BYTE_ARRAY, BSON_BINARY_SUBTYPE_UUID, BSONType, digitByteSize, TWO_PWR_32_DBL_N } from './utils.js'; import { decodeUTF8 } from './strings.js'; import { nodeBufferToArrayBuffer, ReflectionKind, SerializationError, Type } from '@deepkit/type'; import { hexTable } from './model.js'; @@ -32,16 +26,53 @@ export function decodeUTF8Parser(parser: BaseParser, size: number = parser.size return s; } +export function readUint32LE(buffer: Uint8Array, offset: number): number { + return ( + buffer[offset] | + (buffer[offset + 1] << 8) | + (buffer[offset + 2] << 16) | + (buffer[offset + 3] << 24) >>> 0 + ); +} + +export function readInt32LE(buffer: Uint8Array, offset: number): number { + return ( + buffer[offset] | + (buffer[offset + 1] << 8) | + (buffer[offset + 2] << 16) | + (buffer[offset + 3] << 24) + ); +} + +const float64Buffer = new ArrayBuffer(8); +const u32 = new Uint32Array(float64Buffer); +const f64 = new Float64Array(float64Buffer); + +export function readFloat64LE(buffer: Uint8Array, offset: number): number { + u32[0] = + buffer[offset] | + (buffer[offset + 1] << 8) | + (buffer[offset + 2] << 16) | + (buffer[offset + 3] << 24); + u32[1] = + buffer[offset + 4] | + (buffer[offset + 5] << 8) | + (buffer[offset + 6] << 16) | + (buffer[offset + 7] << 24); + return f64[0]; +} + /** * This is the (slowest) base parser which parses all property names as utf8. */ export class BaseParser { public size: number; - public dataView: DataView; - constructor(public buffer: Uint8Array, public offset: number = 0) { + constructor( + public buffer: Uint8Array, + public offset: number = 0, + ) { this.size = buffer.byteLength; - this.dataView = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); } peek(elementType: number, type?: Type) { @@ -271,7 +302,7 @@ export class BaseParser { } peekUInt32(): number { - return this.dataView.getUint32(this.offset, true); + return readUint32LE(this.buffer, this.offset); } /** @@ -304,18 +335,20 @@ export class BaseParser { } eatInt32(): number { + const value = readInt32LE(this.buffer, this.offset); this.offset += 4; - return this.dataView.getInt32(this.offset - 4, true); + return value; } eatUInt32(): number { + const value = readUint32LE(this.buffer, this.offset); this.offset += 4; - return this.dataView.getUint32(this.offset - 4, true); + return value; } eatDouble(): number { + const value = readFloat64LE(this.buffer, this.offset); this.offset += 8; - const value = this.dataView.getFloat64(this.offset - 8, true); if (isNaN(value)) return 0; return value; } diff --git a/packages/bson/src/bson-serializer.ts b/packages/bson/src/bson-serializer.ts index 1c8a3e6e4..4e053db2e 100644 --- a/packages/bson/src/bson-serializer.ts +++ b/packages/bson/src/bson-serializer.ts @@ -8,15 +8,7 @@ * You should have received a copy of the MIT License along with this program. */ -import { - CompilerContext, - createBuffer, - hasProperty, - isArray, - isIterable, - isObject, - toFastProperties, -} from '@deepkit/core'; +import { CompilerContext, createBuffer, hasProperty, isArray, isIterable, isObject, toFastProperties } from '@deepkit/core'; import { binaryBigIntAnnotation, BinaryBigIntType, @@ -100,14 +92,7 @@ import { deserializeUnion, } from './bson-deserializer-templates.js'; import { seekElementSize } from './continuation.js'; -import { - BSON_BINARY_SUBTYPE_DEFAULT, - BSON_BINARY_SUBTYPE_UUID, - BSONType, - digitByteSize, - isSerializable, - TWO_PWR_32_DBL_N, -} from './utils.js'; +import { BSON_BINARY_SUBTYPE_DEFAULT, BSON_BINARY_SUBTYPE_UUID, BSONType, digitByteSize, isSerializable, TWO_PWR_32_DBL_N } from './utils.js'; // BSON MAX VALUES const BSON_INT32_MAX = 0x7fffffff; @@ -1466,8 +1451,7 @@ function createBSONSerializer(type: Type, serializer: BSONBinarySerializer, nami const code = ` state = state || {}; - const size = sizer(data); - state.writer = state.writer || new Writer(createBuffer(size)); + state.writer = state.writer || new Writer(createBuffer(sizer(data))); const unpopulatedCheck = typeSettings.unpopulatedCheck; typeSettings.unpopulatedCheck = UnpopulatedCheck.ReturnSymbol; diff --git a/packages/bson/tests/bson-parser.spec.ts b/packages/bson/tests/bson-parser.spec.ts index f9fa39441..ba5ff01e0 100644 --- a/packages/bson/tests/bson-parser.spec.ts +++ b/packages/bson/tests/bson-parser.spec.ts @@ -1,7 +1,20 @@ import { expect, test } from '@jest/globals'; import bson, { Binary } from 'bson'; import { deserializeBSON, getBSONDeserializer } from '../src/bson-deserializer.js'; -import { BinaryBigInt, copyAndSetParent, MongoId, nodeBufferToArrayBuffer, PrimaryKey, Reference, ReflectionKind, SignedBinaryBigInt, TypeObjectLiteral, typeOf, uuid, UUID } from '@deepkit/type'; +import { + BinaryBigInt, + copyAndSetParent, + MongoId, + nodeBufferToArrayBuffer, + PrimaryKey, + Reference, + ReflectionKind, + SignedBinaryBigInt, + TypeObjectLiteral, + typeOf, + uuid, + UUID, +} from '@deepkit/type'; import { getClassName } from '@deepkit/core'; import { serializeBSONWithoutOptimiser } from '../src/bson-serializer.js'; import { BSONType } from '../src/utils'; @@ -21,7 +34,7 @@ test('basic number', () => { expect(getBSONDeserializer(undefined, schema)(serialize({ v: true }))).toEqual({ v: 1 }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: false }))).toEqual({ v: 0 }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: -1234 }))).toEqual({ v: -1234 }); - expect(() => getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toThrow(`Cannot convert bson type OBJECT to number`); + expect(getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toEqual({ v: 0 }); }); test('basic bigint', () => { @@ -35,7 +48,7 @@ test('basic bigint', () => { expect(getBSONDeserializer(undefined, schema)(serialize({ v: '123' }))).toEqual(obj); expect(getBSONDeserializer(undefined, schema)(serialize({ v: true }))).toEqual({ v: 1n }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: false }))).toEqual({ v: 0n }); - expect(() => getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toThrow(`Cannot convert bson type OBJECT to bigint`); + expect(getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toEqual({ v: 0n }); }); test('basic null', () => { @@ -156,7 +169,7 @@ test('basic binary bigint', () => { expect(getBSONDeserializer(undefined, schema)(serialize({ v: '123' }))).toEqual({ v: 123n }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: true }))).toEqual({ v: 1n }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: false }))).toEqual({ v: 0n }); - expect(() => getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toThrow(`Cannot convert bson type OBJECT to BinaryBigInt`); + expect(getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toEqual({ v: 0n }); }); test('basic signed binary bigint', () => { @@ -173,7 +186,7 @@ test('basic signed binary bigint', () => { expect(getBSONDeserializer(undefined, schema)(serialize({ v: '123' }))).toEqual({ v: 123n }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: true }))).toEqual({ v: 1n }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: false }))).toEqual({ v: 0n }); - expect(() => getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toThrow(`Cannot convert bson type OBJECT to SignedBinaryBigInt`); + expect(getBSONDeserializer(undefined, schema)(serialize({ v: {} }))).toEqual({ v: 0n }); }); test('basic string', () => { @@ -198,7 +211,7 @@ test('basic boolean', () => { expect(getBSONDeserializer(undefined, schema)(bson)).toEqual(obj); expect(getBSONDeserializer(undefined, schema)(serialize({ v: 123 }))).toEqual({ v: true }); expect(getBSONDeserializer(undefined, schema)(serialize({ v: 0 }))).toEqual({ v: false }); - expect(() => getBSONDeserializer(undefined, schema)(serialize({ v: '123' }))).toThrow(`Cannot convert bson type STRING to boolean`); + expect(getBSONDeserializer(undefined, schema)(serialize({ v: '123' }))).toEqual({ v: true }); }); test('basic array buffer', () => { From e4f22dde96b29649cde6fc90c4994e802708ebee Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 18:35:02 +0200 Subject: [PATCH 09/10] chore: update deps --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 0e64807ae..794563d06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3024,6 +3024,7 @@ __metadata: version: 0.0.0-use.local resolution: "@deepkit/core@workspace:packages/core" dependencies: + "@deepkit/bench": "npm:^1.0.3" "@types/dot-prop": "npm:~4.2.0" dot-prop: "npm:^5.1.1" to-fast-properties: "npm:^3.0.1" From b46e228428d6b09772a9a5161a7b261f93db585e Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Wed, 2 Apr 2025 19:03:30 +0200 Subject: [PATCH 10/10] fix(bson): bigint with isNaN checks --- packages/bson/src/bson-deserializer-templates.ts | 4 ++-- packages/bson/src/bson-serializer.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/bson/src/bson-deserializer-templates.ts b/packages/bson/src/bson-deserializer-templates.ts index 3ca578e35..8f145e016 100644 --- a/packages/bson/src/bson-deserializer-templates.ts +++ b/packages/bson/src/bson-deserializer-templates.ts @@ -78,8 +78,8 @@ export function deserializeAny(type: Type, state: TemplateState) { const numberParsers = createParserLookup(() => 0, [ [BSONType.INT, parser => parser.parseInt()], [BSONType.NUMBER, parser => parser.parseNumber()], - [BSONType.LONG, parser => parser.parseLong()], - [BSONType.TIMESTAMP, parser => parser.parseLong()], + [BSONType.LONG, parser => Number(parser.parseLong())], + [BSONType.TIMESTAMP, parser => Number(parser.parseLong())], [BSONType.BOOLEAN, parser => parser.parseBoolean() ? 1 : 0], [BSONType.BINARY, parser => Number(parser.parseBinaryBigInt())], [BSONType.STRING, parser => Number(parser.parseString())], diff --git a/packages/bson/src/bson-serializer.ts b/packages/bson/src/bson-serializer.ts index 4e053db2e..17b056e47 100644 --- a/packages/bson/src/bson-serializer.ts +++ b/packages/bson/src/bson-serializer.ts @@ -909,7 +909,7 @@ function sizerNumber(type: Type, state: TemplateState) { state.setContext({ getValueSize }); //per default bigint will be serialized as long, to be compatible with default mongo driver and mongo database. //We should add a new annotation, maybe like `bigint & Binary` to make it binary (unlimited size) - sizerPropertyNameAware(type, state, `(typeof ${state.accessor} === 'number' || typeof ${state.accessor} === 'bigint') && !Number.isNaN(${state.accessor})`, ` + sizerPropertyNameAware(type, state, `typeof ${state.accessor} === 'bigint' || (typeof ${state.accessor} === 'number' && !Number.isNaN(${state.accessor}))`, ` state.size += getValueSize(${state.accessor}); `); } @@ -986,7 +986,7 @@ function sizerBigInt(type: TypeBigInt, state: TemplateState) { const bigIntSize = binaryBigInt === BinaryBigIntType.unsigned ? 'getBinaryBigIntSize' : 'getSignedBinaryBigIntSize'; //per default bigint will be serialized as long, to be compatible with default mongo driver and mongo database. //We should add a new annotation, maybe like `bigint & Binary` to make it binary (unlimited size) - sizerPropertyNameAware(type, state, `(typeof ${state.accessor} === 'number' || typeof ${state.accessor} === 'bigint') && !Number.isNaN(${state.accessor})`, ` + sizerPropertyNameAware(type, state, `typeof ${state.accessor} === 'bigint' || (typeof ${state.accessor} === 'number' && !Number.isNaN(${state.accessor}))`, ` state.size += ${bigIntSize}(${state.accessor}); `); } else { @@ -1000,7 +1000,7 @@ function serializeBigInt(type: TypeBigInt, state: TemplateState) { if (binaryBigInt !== undefined) { const writeBigInt = binaryBigInt === BinaryBigIntType.unsigned ? 'writeBigIntBinary' : 'writeSignedBigIntBinary'; state.addCode(` - if (('bigint' === typeof ${state.accessor} || 'number' === typeof ${state.accessor}) && !Number.isNaN(${state.accessor})) { + if ('bigint' === typeof ${state.accessor} || ('number' === typeof ${state.accessor} && !Number.isNaN(${state.accessor}))) { //long state.writer.writeType(${BSONType.BINARY}); state.writer.${writeBigInt}(${state.accessor});