Skip to content

Commit efac86d

Browse files
committed
feat(injecta): new injecta package for helping doing dependency injection
1 parent d5e3d24 commit efac86d

File tree

11 files changed

+397
-31
lines changed

11 files changed

+397
-31
lines changed

apps/stage-tamagotchi/src/main/windows/settings/index.ts

Whitespace-only changes.

cspell.config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ words:
117117
- hunyuan
118118
- hyoban
119119
- iconify
120+
- injecta
120121
- intlify
121122
- jlumbroso
122123
- jszip
@@ -251,6 +252,7 @@ words:
251252
- togetherapi
252253
- tolist
253254
- tresjs
255+
- Triggerable
254256
- tsdown
255257
- turborepo
256258
- unbird

packages/injecta/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@proj-airi/injecta",
3+
"type": "module",
4+
"private": true,
5+
"description": "Dependency Injection (DI) utilities",
6+
"author": {
7+
"name": "Moeru AI Project AIRI Team",
8+
"email": "airi@moeru.ai",
9+
"url": "https://github.com/moeru-ai"
10+
},
11+
"license": "MIT",
12+
"repository": {
13+
"type": "git",
14+
"url": "https://github.com/moeru-ai/airi.git",
15+
"directory": "packages/injecta"
16+
},
17+
"scripts": {
18+
"typecheck": "vue-tsc --noEmit"
19+
}
20+
}

packages/injecta/src/builtin.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export interface LifecycleAppHooks {
2+
onStart: (hook: () => void | Promise<void>) => void
3+
onStop: (hook: () => void | Promise<void>) => void
4+
}
5+
6+
export interface Lifecycle {
7+
appHooks: LifecycleAppHooks
8+
}
9+
10+
export interface LifecycleTriggerable extends Lifecycle {
11+
emitOnStart: () => Promise<void>
12+
emitOnStop: () => Promise<void>
13+
}
14+
15+
export function buildLifecycle(): Lifecycle {
16+
const onStartHooks: (() => void | Promise<void>)[] = []
17+
const onStopHooks: (() => void | Promise<void>)[] = []
18+
19+
const lifecycle: LifecycleTriggerable = {
20+
appHooks: {
21+
onStart(hook: () => void | Promise<void>) {
22+
if (hook) {
23+
onStartHooks.push(hook)
24+
}
25+
},
26+
onStop(hook: () => void | Promise<void>) {
27+
if (hook) {
28+
onStopHooks.push(hook)
29+
}
30+
},
31+
},
32+
emitOnStart: async () => {
33+
for (const hook of onStartHooks) {
34+
await hook()
35+
}
36+
},
37+
emitOnStop: async () => {
38+
for (const hook of onStopHooks) {
39+
await hook()
40+
}
41+
},
42+
}
43+
44+
return lifecycle
45+
}

packages/injecta/src/global.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { DependencyMap, InvokeOption, ProvideOption } from '.'
2+
3+
import { createContainer, provide as indexProvide, start as indexStart, stop as indexStop } from '.'
4+
5+
const globalContainer = createContainer()
6+
7+
export function provide<D extends DependencyMap | undefined, T = any>(
8+
name: string,
9+
option: ProvideOption<T, D>,
10+
): void {
11+
indexProvide(globalContainer, name, option)
12+
}
13+
14+
export function invoke<D extends DependencyMap>(option: InvokeOption<D>): void {
15+
if (typeof option === 'function') {
16+
globalContainer.invocations.push({ callback: option } as any)
17+
}
18+
else {
19+
globalContainer.invocations.push(option as any)
20+
}
21+
}
22+
23+
export function start(): Promise<void> {
24+
return indexStart(globalContainer)
25+
}
26+
27+
export function stop(): Promise<void> {
28+
return indexStop(globalContainer)
29+
}

packages/injecta/src/index.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Lifecycle } from './builtin'
2+
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
import { invoke, provide, start, stop } from './global'
6+
7+
describe('di', () => {
8+
it('should work with individual lifecycle injection', async () => {
9+
interface Database {
10+
connect: () => Promise<void>
11+
close: () => Promise<void>
12+
}
13+
14+
const databaseConnectSpy = vi.fn()
15+
const databaseCloseSpy = vi.fn()
16+
17+
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
18+
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
19+
params.lifecycle?.appHooks.onStop(async () => await database.close())
20+
return database
21+
}
22+
23+
interface WebSocketServer {
24+
start: () => Promise<void>
25+
stop: () => Promise<void>
26+
}
27+
28+
const webSocketServerStartSpy = vi.fn()
29+
const webSocketServerStopSpy = vi.fn()
30+
31+
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
32+
await params.database.connect()
33+
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
34+
params.lifecycle?.appHooks.onStop(async () => await server.stop())
35+
return server
36+
}
37+
38+
provide<{ lifecycle: Lifecycle }>('db', {
39+
dependsOn: { lifecycle: 'lifecycle' },
40+
build: async ({ dependsOn }) => createDatabase({ lifecycle: dependsOn.lifecycle }),
41+
})
42+
43+
provide<{ database: Database, lifecycle: Lifecycle }>('ws', {
44+
dependsOn: { database: 'db', lifecycle: 'lifecycle' },
45+
build: async ({ dependsOn }) => createWebSocketServer({ database: dependsOn.database, lifecycle: dependsOn.lifecycle }),
46+
})
47+
48+
invoke<{ webSocketServer: WebSocketServer }>({
49+
dependsOn: { webSocketServer: 'ws' },
50+
callback: async ({ webSocketServer }) => await webSocketServer.start(),
51+
})
52+
53+
await start()
54+
await stop()
55+
56+
// eslint-disable-next-line no-lone-blocks
57+
{
58+
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
59+
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
60+
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
61+
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
62+
}
63+
})
64+
})

packages/injecta/src/index.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import type { LifecycleTriggerable } from './builtin'
2+
3+
export type DependencyMap = Record<string, any>
4+
5+
export interface Container {
6+
providers: Map<string, ProvideOptionObject<any, any>>
7+
instances: Map<string, any>
8+
lifecycleHooks: Map<string, LifecycleTriggerable>
9+
dependencyGraph: Map<string, string[]>
10+
invocations: InvokeOptionObject<any>[]
11+
}
12+
13+
export type BuildContext<D extends DependencyMap | undefined = undefined> = {
14+
container: Container
15+
name: string
16+
} & (D extends undefined ? { dependsOn?: unknown } : { dependsOn: D })
17+
18+
export type ProvideOptionObject<T, D extends DependencyMap | undefined = undefined> = { build: (context: BuildContext<D>) => T | Promise<T> } & (D extends undefined ? { dependsOn?: Record<string, never> } : { dependsOn: { [K in keyof D]: string } })
19+
export type ProvideOptionFunc<T, D extends DependencyMap | undefined = undefined> = (context: BuildContext<D>) => T | Promise<T>
20+
export type ProvideOption<T, D extends DependencyMap | undefined = undefined> = ProvideOptionObject<T, D> | ProvideOptionFunc<T, D>
21+
22+
export type InvokeOptionObject<D extends DependencyMap | undefined = undefined> = { callback: (dependencies: D) => void | Promise<void> } & (D extends undefined ? { dependsOn?: Record<string, never> } : { dependsOn: { [K in keyof D]: string } })
23+
export type InvokeOptionFunc<D extends DependencyMap | undefined = undefined> = (dependencies: D) => void | Promise<void>
24+
export type InvokeOption<D extends DependencyMap | undefined = undefined> = InvokeOptionObject<D> | InvokeOptionFunc<D>
25+
26+
export function createContainer(): Container {
27+
return {
28+
providers: new Map(),
29+
instances: new Map(),
30+
lifecycleHooks: new Map(),
31+
dependencyGraph: new Map(),
32+
invocations: [],
33+
}
34+
}
35+
36+
export function provide<D extends DependencyMap | undefined, T = any>(
37+
container: Container,
38+
name: string,
39+
option: ProvideOption<T, D>,
40+
): void {
41+
const providerObject = typeof option === 'function'
42+
? { build: option } as ProvideOptionObject<any, any>
43+
: option as ProvideOptionObject<any, any>
44+
45+
container.providers.set(name, providerObject)
46+
47+
// Track dependencies for lifecycle ordering
48+
const dependencies = providerObject.dependsOn ? Object.values(providerObject.dependsOn) : []
49+
container.dependencyGraph.set(name, dependencies)
50+
}
51+
52+
export function invoke<D extends DependencyMap>(container: Container, option: InvokeOption<D>): void {
53+
if (typeof option === 'function') {
54+
container.invocations.push({ callback: option } as InvokeOptionObject<any>)
55+
}
56+
else {
57+
container.invocations.push(option as InvokeOptionObject<any>)
58+
}
59+
}
60+
61+
async function resolveInstance<T>(container: Container, name: string): Promise<T> {
62+
if (container.instances.has(name)) {
63+
return container.instances.get(name) as T
64+
}
65+
66+
const provider = container.providers.get(name)
67+
if (!provider) {
68+
throw new Error(`No provider found for '${name}'`)
69+
}
70+
71+
const resolvedDependencies: Record<string, any> = {}
72+
let serviceLifecycle: any = null
73+
74+
if (provider.dependsOn) {
75+
for (const [key, depName] of Object.entries(provider.dependsOn)) {
76+
if (depName === 'lifecycle') {
77+
// Create individual lifecycle instance for this service
78+
const { buildLifecycle } = await import('./builtin')
79+
serviceLifecycle = buildLifecycle()
80+
resolvedDependencies[key] = serviceLifecycle
81+
// Track this service's lifecycle
82+
container.lifecycleHooks.set(name, serviceLifecycle)
83+
}
84+
else {
85+
resolvedDependencies[key] = await resolveInstance(container, depName)
86+
}
87+
}
88+
}
89+
90+
const context: BuildContext<typeof provider.dependsOn> = {
91+
container,
92+
dependsOn: resolvedDependencies,
93+
name,
94+
}
95+
96+
const instance = await provider.build(context)
97+
container.instances.set(name, instance)
98+
99+
return instance as T
100+
}
101+
102+
function topologicalSort(dependencyGraph: Map<string, string[]>): string[] {
103+
const visited = new Set<string>()
104+
const visiting = new Set<string>()
105+
const result: string[] = []
106+
107+
function visit(node: string) {
108+
if (visiting.has(node)) {
109+
throw new Error(`Circular dependency detected involving '${node}'`)
110+
}
111+
if (visited.has(node)) {
112+
return
113+
}
114+
115+
visiting.add(node)
116+
const dependencies = dependencyGraph.get(node) || []
117+
for (const dep of dependencies) {
118+
visit(dep)
119+
}
120+
visiting.delete(node)
121+
visited.add(node)
122+
result.push(node)
123+
}
124+
125+
for (const node of dependencyGraph.keys()) {
126+
if (!visited.has(node)) {
127+
visit(node)
128+
}
129+
}
130+
131+
return result
132+
}
133+
134+
export async function startLifecycleHooks(container: Container): Promise<void> {
135+
const sortedServices = topologicalSort(container.dependencyGraph)
136+
137+
for (const serviceName of sortedServices) {
138+
const lifecycle = container.lifecycleHooks.get(serviceName)
139+
if (lifecycle?.emitOnStart) {
140+
await lifecycle.emitOnStart()
141+
}
142+
}
143+
}
144+
145+
export async function stopLifecycleHooks(container: Container): Promise<void> {
146+
const sortedServices = topologicalSort(container.dependencyGraph)
147+
148+
// Shutdown in reverse order
149+
for (const serviceName of sortedServices.reverse()) {
150+
const lifecycle = container.lifecycleHooks.get(serviceName)
151+
if (lifecycle?.emitOnStop) {
152+
await lifecycle.emitOnStop()
153+
}
154+
}
155+
}
156+
157+
export async function start(container: Container): Promise<void> {
158+
await startLifecycleHooks(container)
159+
160+
for (const invocation of container.invocations) {
161+
const resolvedDependencies: Record<string, any> = {}
162+
163+
if (invocation.dependsOn) {
164+
for (const [key, depName] of Object.entries(invocation.dependsOn)) {
165+
resolvedDependencies[key] = await resolveInstance(container, depName)
166+
}
167+
}
168+
169+
await invocation.callback(resolvedDependencies)
170+
}
171+
}
172+
173+
export async function stop(container: Container): Promise<void> {
174+
await stopLifecycleHooks(container)
175+
}

packages/injecta/tsconfig.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"jsx": "preserve",
5+
"lib": [
6+
"DOM",
7+
"ESNext"
8+
],
9+
"module": "ESNext",
10+
"moduleResolution": "Bundler",
11+
"resolveJsonModule": true,
12+
"types": [
13+
"vitest",
14+
"vite/client"
15+
],
16+
"allowJs": true,
17+
"strict": true,
18+
"strictNullChecks": true,
19+
"noUnusedLocals": true,
20+
"noEmit": true,
21+
"esModuleInterop": true,
22+
"forceConsistentCasingInFileNames": true,
23+
"isolatedModules": true,
24+
"verbatimModuleSyntax": true,
25+
"skipLibCheck": true
26+
},
27+
"include": [
28+
"src/**/*.ts",
29+
"src/**/*.d.ts",
30+
"src/**/*.mts"
31+
],
32+
"exclude": [
33+
"dist",
34+
"node_modules"
35+
]
36+
}

packages/injecta/vitest.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
include: ['src/**/*.test.ts'],
6+
},
7+
})

0 commit comments

Comments
 (0)