chore: use injeca

This commit is contained in:
Neko Ayaka
2025-11-16 01:29:59 +08:00
parent 5b9bf180e9
commit 13b815172c
15 changed files with 50 additions and 1093 deletions
+1 -1
View File
@@ -44,7 +44,6 @@
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/injecta": "workspace:*",
"@proj-airi/server-runtime": "workspace:^",
"@proj-airi/server-sdk": "workspace:*",
"@proj-airi/stage-ui": "workspace:^",
@@ -81,6 +80,7 @@
"electron-click-drag-plugin": "^2.0.2",
"es-toolkit": "^1.41.0",
"h3": "2.0.1-rc.5",
"injeca": "^0.1.1",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"mediabunny": "^1.24.2",
+12 -12
View File
@@ -6,9 +6,9 @@ import { platform } from 'node:process'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { createLoggLogger, injecta } from '@proj-airi/injecta'
import { app, ipcMain, Menu, nativeImage, Tray } from 'electron'
import { noop, once } from 'es-toolkit'
import { createLoggLogger, injeca } from 'injeca'
import { isLinux, isMacOS } from 'std-env'
import icon from '../../resources/icon.png?asset'
@@ -98,34 +98,34 @@ function setupTray(params: {
}
app.whenReady().then(async () => {
injecta.setLogger(createLoggLogger(useLogg('injecta').useGlobalConfig()))
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
const channelServerModule = injecta.provide('modules:channel-server', async () => setupChannelServer())
const chatWindow = injecta.provide('windows:chat', { build: () => setupChatWindowReusableFunc() })
const widgetsManager = injecta.provide('windows:widgets', { build: () => setupWidgetsWindowManager() })
const channelServerModule = injeca.provide('modules:channel-server', async () => setupChannelServer())
const chatWindow = injeca.provide('windows:chat', { build: () => setupChatWindowReusableFunc() })
const widgetsManager = injeca.provide('windows:widgets', { build: () => setupWidgetsWindowManager() })
const settingsWindow = injecta.provide('windows:settings', {
const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager },
build: ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
})
const mainWindow = injecta.provide('windows:main', {
const mainWindow = injeca.provide('windows:main', {
dependsOn: { settingsWindow, chatWindow, widgetsManager },
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
})
const captionWindow = injecta.provide('windows:caption', {
const captionWindow = injeca.provide('windows:caption', {
dependsOn: { mainWindow },
build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn),
})
const tray = injecta.provide('app:tray', {
const tray = injeca.provide('app:tray', {
dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager },
build: async ({ dependsOn }) => setupTray(dependsOn),
})
injecta.invoke({
injeca.invoke({
dependsOn: { mainWindow, tray, channelServerModule },
callback: noop,
})
injecta.start().catch(err => console.error(err))
injeca.start().catch(err => console.error(err))
// Lifecycle
emitAppReady()
@@ -155,5 +155,5 @@ app.on('window-all-closed', () => {
// Clean up server and intervals when app quits
app.on('before-quit', async () => {
emitAppBeforeQuit()
injecta.stop()
injeca.stop()
})
+1
View File
@@ -123,6 +123,7 @@ words:
- hunyuan
- hyoban
- iconify
- injeca
- injecta
- intlify
- jlumbroso
-32
View File
@@ -1,32 +0,0 @@
{
"name": "@proj-airi/injecta",
"type": "module",
"private": true,
"description": "Dependency Injection (DI) utilities",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/injecta"
},
"exports": {
".": {
"type": "./dist/index.d.js",
"default": "./dist/index.js"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsdown"
},
"devDependencies": {
"@guiiai/logg": "catalog:",
"error-stack-parser": "^2.1.4",
"nanoid": "^5.1.6"
}
}
-45
View File
@@ -1,45 +0,0 @@
export interface LifecycleAppHooks {
onStart: (hook: () => void | Promise<void>) => void
onStop: (hook: () => void | Promise<void>) => void
}
export interface Lifecycle {
appHooks: LifecycleAppHooks
}
export interface LifecycleTriggerable extends Lifecycle {
emitOnStart: () => Promise<void>
emitOnStop: () => Promise<void>
}
export function buildLifecycle(): Lifecycle {
const onStartHooks: (() => void | Promise<void>)[] = []
const onStopHooks: (() => void | Promise<void>)[] = []
const lifecycle: LifecycleTriggerable = {
appHooks: {
onStart(hook: () => void | Promise<void>) {
if (hook) {
onStartHooks.push(hook)
}
},
onStop(hook: () => void | Promise<void>) {
if (hook) {
onStopHooks.push(hook)
}
},
},
emitOnStart: async () => {
for (const hook of onStartHooks) {
await hook()
}
},
emitOnStop: async () => {
for (const hook of onStopHooks) {
await hook()
}
},
}
return lifecycle
}
-187
View File
@@ -1,187 +0,0 @@
import type { Lifecycle } from './builtin'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { invoke, provide, resetContainer, start, stop } from './global'
import { lifecycle } from './scoped'
beforeEach(() => {
resetContainer()
})
describe('workflow with named', () => {
it('should work with named pattern', async () => {
interface Database {
connect: () => Promise<void>
close: () => Promise<void>
}
const databaseConnectSpy = vi.fn()
const databaseCloseSpy = vi.fn()
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
params.lifecycle?.appHooks.onStop(async () => await database.close())
return database
}
interface WebSocketServer {
start: () => Promise<void>
stop: () => Promise<void>
}
const webSocketServerStartSpy = vi.fn()
const webSocketServerStopSpy = vi.fn()
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
await params.database.connect()
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
params.lifecycle?.appHooks.onStop(async () => await server.stop())
return server
}
provide<{ lifecycle: Lifecycle }>('db', {
dependsOn: { lifecycle: 'lifecycle' },
build: async ({ dependsOn }) => createDatabase({ lifecycle: dependsOn.lifecycle }),
})
provide<{ database: Database, lifecycle: Lifecycle }>('ws', {
dependsOn: { database: 'db', lifecycle: 'lifecycle' },
build: async ({ dependsOn }) => createWebSocketServer({ database: dependsOn.database, lifecycle: dependsOn.lifecycle }),
})
invoke<{ webSocketServer: WebSocketServer }>({
dependsOn: { webSocketServer: 'ws' },
callback: async ({ webSocketServer }) => await webSocketServer.start(),
})
await start()
await stop()
// eslint-disable-next-line no-lone-blocks
{
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
}
})
})
describe('workflow with typed', () => {
it('should work with typed named pattern', async () => {
interface Database {
connect: () => Promise<void>
close: () => Promise<void>
}
const databaseConnectSpy = vi.fn()
const databaseCloseSpy = vi.fn()
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
params.lifecycle?.appHooks.onStop(async () => await database.close())
return database
}
interface WebSocketServer {
start: () => Promise<void>
stop: () => Promise<void>
}
const webSocketServerStartSpy = vi.fn()
const webSocketServerStopSpy = vi.fn()
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
await params.database.connect()
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
params.lifecycle?.appHooks.onStop(async () => await server.stop())
return server
}
const database = provide('db', {
dependsOn: { lifecycle },
build: async ({ dependsOn }) => createDatabase(dependsOn),
})
const webSocketServer = provide('ws', {
dependsOn: { database, lifecycle },
build: async ({ dependsOn }) => createWebSocketServer(dependsOn),
})
invoke({
dependsOn: { webSocketServer },
callback: async ({ webSocketServer }) => await webSocketServer.start(),
})
await start()
await stop()
// eslint-disable-next-line no-lone-blocks
{
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
}
})
})
describe('workflow with auto name', () => {
it('should work with auto name pattern', async () => {
interface Database {
connect: () => Promise<void>
close: () => Promise<void>
}
const databaseConnectSpy = vi.fn()
const databaseCloseSpy = vi.fn()
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
params.lifecycle?.appHooks.onStop(async () => await database.close())
return database
}
interface WebSocketServer {
start: () => Promise<void>
stop: () => Promise<void>
}
const webSocketServerStartSpy = vi.fn()
const webSocketServerStopSpy = vi.fn()
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
await params.database.connect()
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
params.lifecycle?.appHooks.onStop(async () => await server.stop())
return server
}
const database = provide({
dependsOn: { lifecycle },
build: async ({ dependsOn }) => createDatabase(dependsOn),
})
const webSocketServer = provide({
dependsOn: { database, lifecycle },
build: async ({ dependsOn }) => createWebSocketServer(dependsOn),
})
invoke({
dependsOn: { webSocketServer },
callback: async ({ webSocketServer }) => await webSocketServer.start(),
})
await start()
await stop()
// eslint-disable-next-line no-lone-blocks
{
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
}
})
})
-45
View File
@@ -1,45 +0,0 @@
import type { DependencyMap, InvokeOption, InvokeOptionWithKeys, Logger, ProvideOption, ProvideOptionWithKeys, ResolveDependencyDeclaration } from '.'
import type { ProvidedKey } from './scoped'
import { createContainer, provide as indexProvide, start as indexStart, stop as indexStop } from '.'
let globalContainer = createContainer()
export function setLogger(logger: Logger) {
globalContainer.logger = logger
}
export function resetContainer() {
globalContainer = createContainer()
}
export function provide<T, Key extends string, Deps extends Record<string, string | ProvidedKey<any, any, any>>>(name: Key, option: ProvideOptionWithKeys<T, Deps>): ProvidedKey<Key, T, ResolveDependencyDeclaration<Deps>>
export function provide<D extends DependencyMap | undefined, T = any, Key extends string = string>(name: Key, option: ProvideOption<T, D>): ProvidedKey<Key, T, D>
export function provide<T, Key extends string, Deps extends Record<string, string | ProvidedKey<any, any, any>>>(option: ProvideOptionWithKeys<T, Deps>): ProvidedKey<Key, T, ResolveDependencyDeclaration<Deps>>
export function provide<D extends DependencyMap | undefined, T = any, Key extends string = string>(option: ProvideOption<T, D>): ProvidedKey<Key, T, D>
export function provide<D extends DependencyMap | undefined, T = any, Key extends string = string>(nameOrOption: Key | ProvideOption<T, D> | ProvideOptionWithKeys<T, any>, option?: ProvideOption<T, D> | ProvideOptionWithKeys<T, any>): ProvidedKey<Key, T, D> {
if (option != null && typeof option === 'function') {
return indexProvide(globalContainer, nameOrOption as any, { build: option, autoNameStackIndex: 2 } as any)
}
return indexProvide(globalContainer, nameOrOption as any, { ...option, autoNameStackIndex: 2 } as any)
}
export function invoke<Deps extends Record<string, string | ProvidedKey<any, any, any>>>(option: InvokeOptionWithKeys<Deps>): void
export function invoke<D extends DependencyMap>(option: InvokeOption<D>): void
export function invoke<D extends DependencyMap>(option: InvokeOption<D> | InvokeOptionWithKeys<any>): void {
if (typeof option === 'function') {
globalContainer.invocations.push({ callback: option } as any)
}
else {
globalContainer.invocations.push(option as any)
}
}
export function start(): Promise<void> {
return indexStart(globalContainer)
}
export function stop(): Promise<void> {
return indexStop(globalContainer)
}
-51
View File
@@ -1,51 +0,0 @@
import {
invoke as globalInvoke,
provide as globalProvide,
setLogger as globalSetLogger,
start as globalStart,
stop as globalStop,
} from './global'
export type { Lifecycle } from './builtin'
export type { Logger, LoggerOptions } from './logger'
export {
createDefaultLogger,
createLoggLogger,
createNoopLogger,
} from './logger'
export {
createContainer,
invoke,
lifecycle,
provide,
start,
stop,
} from './scoped'
export type {
BuildContext,
Container,
DependencyMap,
InvokeOption,
InvokeOptionFunc,
InvokeOptionObject,
InvokeOptionObjectWithKeys,
InvokeOptionWithKeys,
ProvidedKey,
ProvideOption,
ProvideOptionFunc,
ProvideOptionObject,
ProvideOptionObjectWithKeys,
ProvideOptionWithKeys,
ResolveDependencyDeclaration,
} from './scoped'
export const injecta = {
provide: globalProvide,
invoke: globalInvoke,
start: globalStart,
stop: globalStop,
setLogger: globalSetLogger,
}
-137
View File
@@ -1,137 +0,0 @@
import type { Logg as LoggLogger } from '@guiiai/logg'
import { name } from '../package.json'
export interface Logger {
provide: (name: string, dependencies: string[]) => void
invoke: (dependencies: string[]) => void
beforeRun: (name: string) => void
run: (name: string, duration: number) => void
hookOnStart: (name: string) => void
hookOnStartComplete: (name: string, duration: number) => void
hookOnStop: (name: string) => void
hookOnStopComplete: (name: string, duration: number) => void
running: () => void
}
export interface LoggerOptions {
enabled?: boolean
logger?: Logger
}
function formatDuration(ms: number): string {
if (ms < 1) {
return `${(ms * 1000).toFixed(3)}µs`
}
if (ms < 1000) {
return `${ms.toFixed(3)}ms`
}
return `${(ms / 1000).toFixed(3)}s`
}
export function createDefaultLogger(): Logger {
return {
provide: (name: string, dependencies: string[]) => {
const depsStr = dependencies.length > 0 ? ` (depends on: ${dependencies.join(', ')})` : ''
// eslint-disable-next-line no-console
console.log(`[${name}] PROVIDE ${name}${depsStr}`)
},
invoke: (dependencies: string[]) => {
const depsStr = dependencies.length > 0 ? ` (depends on: ${dependencies.join(', ')})` : ''
// eslint-disable-next-line no-console
console.log(`[${name}] INVOKE${depsStr}`)
},
beforeRun: (name: string) => {
// eslint-disable-next-line no-console
console.log(`[${name}] BEFORE RUN provide: ${name}()`)
},
run: (name: string, duration: number) => {
// eslint-disable-next-line no-console
console.log(`[${name}] RUN provide: ${name}() in ${formatDuration(duration)}`)
},
hookOnStart: (name: string) => {
// eslint-disable-next-line no-console
console.log(`[${name}] HOOK OnStart ${name}() executing`)
},
hookOnStartComplete: (name: string, duration: number) => {
// eslint-disable-next-line no-console
console.log(`[${name}] HOOK OnStart ${name}() ran successfully in ${formatDuration(duration)}`)
},
hookOnStop: (name: string) => {
// eslint-disable-next-line no-console
console.log(`[${name}] HOOK OnStop ${name}() executing`)
},
hookOnStopComplete: (name: string, duration: number) => {
// eslint-disable-next-line no-console
console.log(`[${name}] HOOK OnStop ${name}() ran successfully in ${formatDuration(duration)}`)
},
running: () => {
// eslint-disable-next-line no-console
console.log(`[${name}] RUNNING`)
},
}
}
export function createLoggLogger(logg: LoggLogger): Logger {
return {
provide: (name: string, dependencies: string[]) => {
const depsStr = dependencies.length > 0 ? ` (depends on: ${dependencies.join(', ')})` : ''
logg.log(`PROVIDE ${name}${depsStr}`)
},
invoke: (dependencies: string[]) => {
const depsStr = dependencies.length > 0 ? ` (depends on: ${dependencies.join(', ')})` : ''
logg.log(`INVOKE${depsStr}`)
},
beforeRun: (name: string) => {
logg.log(`BEFORE RUN provide: ${name}()`)
},
run: (name: string, duration: number) => {
logg.log(`RUN provide: ${name}() in ${formatDuration(duration)}`)
},
hookOnStart: (name: string) => {
logg.log(`HOOK OnStart ${name}() executing`)
},
hookOnStartComplete: (name: string, duration: number) => {
logg.log(`HOOK OnStart ${name}() ran successfully in ${formatDuration(duration)}`)
},
hookOnStop: (name: string) => {
logg.log(`HOOK OnStop ${name}() executing`)
},
hookOnStopComplete: (name: string, duration: number) => {
logg.log(`HOOK OnStop ${name}() ran successfully in ${formatDuration(duration)}`)
},
running: () => {
logg.log(`RUNNING`)
},
}
}
export function createNoopLogger(): Logger {
return {
provide: () => {},
invoke: () => {},
beforeRun: () => {},
run: () => {},
hookOnStart: () => {},
hookOnStartComplete: () => {},
hookOnStop: () => {},
hookOnStopComplete: () => {},
running: () => {},
}
}
-217
View File
@@ -1,217 +0,0 @@
import type { Lifecycle } from './builtin'
import { describe, expect, it, vi } from 'vitest'
import { createContainer, invoke, lifecycle, normalizeName, normalizeProvideOption, provide, start, stop } from './scoped'
describe('normalizeName', () => {
it('should normalize names correctly', () => {
expect(normalizeName('simpleName')).toBe('simpleName')
expect(normalizeName({ key: 'objectName' })).toBe('objectName')
})
})
describe('normalizeProvideOption', () => {
it('should normalize provide options correctly', () => {
expect(() => normalizeProvideOption('simpleName')).toThrowError('When using provide(...) as named callback, the second argument must be either a valid ProvideOptionObject<T, D> or a ProvideOptionFunc<T, D>.')
expect(() => normalizeProvideOption({ key: 'objectName' })).toThrowError('When using provide(...) as typed ProvideOptionWithKeys<T, D, Key> callback, the second argument must be either a valid ProvideOptionObject<T, D> or a ProvideOptionFunc<T, D>.')
const namedOption = normalizeProvideOption('name', () => {})
expect(namedOption).toBeTypeOf('object')
expect(namedOption).toHaveProperty('build')
expect(() => namedOption.build({} as any)).not.toThrow()
const typedOption = normalizeProvideOption({ key: 'name' }, () => {})
expect(typedOption).toBeTypeOf('object')
expect(typedOption).toHaveProperty('build')
expect(() => typedOption.build({} as any)).not.toThrow()
const autoNameOption = normalizeProvideOption(() => {})
expect(autoNameOption).toBeTypeOf('object')
expect(autoNameOption).toHaveProperty('build')
expect(() => autoNameOption.build({} as any)).not.toThrow()
})
})
describe('workflow with named', () => {
it('should work with named pattern', async () => {
interface Database {
connect: () => Promise<void>
close: () => Promise<void>
}
const databaseConnectSpy = vi.fn()
const databaseCloseSpy = vi.fn()
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
params.lifecycle?.appHooks.onStop(async () => await database.close())
return database
}
interface WebSocketServer {
start: () => Promise<void>
stop: () => Promise<void>
}
const webSocketServerStartSpy = vi.fn()
const webSocketServerStopSpy = vi.fn()
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
await params.database.connect()
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
params.lifecycle?.appHooks.onStop(async () => await server.stop())
return server
}
const app = createContainer()
provide<{ lifecycle: Lifecycle }>(app, 'db', {
dependsOn: { lifecycle: 'lifecycle' },
build: async ({ dependsOn }) => createDatabase({ lifecycle: dependsOn.lifecycle }),
})
provide<{ database: Database, lifecycle: Lifecycle }>(app, 'ws', {
dependsOn: { database: 'db', lifecycle: 'lifecycle' },
build: async ({ dependsOn }) => createWebSocketServer({ database: dependsOn.database, lifecycle: dependsOn.lifecycle }),
})
invoke<{ webSocketServer: WebSocketServer }>(app, {
dependsOn: { webSocketServer: 'ws' },
callback: async ({ webSocketServer }) => await webSocketServer.start(),
})
await start(app)
await stop(app)
// eslint-disable-next-line no-lone-blocks
{
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
}
})
})
describe('workflow with typed', () => {
it('should work with typed named pattern', async () => {
interface Database {
connect: () => Promise<void>
close: () => Promise<void>
}
const databaseConnectSpy = vi.fn()
const databaseCloseSpy = vi.fn()
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
params.lifecycle?.appHooks.onStop(async () => await database.close())
return database
}
interface WebSocketServer {
start: () => Promise<void>
stop: () => Promise<void>
}
const webSocketServerStartSpy = vi.fn()
const webSocketServerStopSpy = vi.fn()
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
await params.database.connect()
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
params.lifecycle?.appHooks.onStop(async () => await server.stop())
return server
}
const app = createContainer()
const database = provide(app, 'db', {
dependsOn: { lifecycle },
build: async ({ dependsOn }) => createDatabase(dependsOn),
})
const webSocketServer = provide(app, 'ws', {
dependsOn: { database, lifecycle },
build: async ({ dependsOn }) => createWebSocketServer(dependsOn),
})
invoke(app, {
dependsOn: { webSocketServer },
callback: async ({ webSocketServer }) => await webSocketServer.start(),
})
await start(app)
await stop(app)
// eslint-disable-next-line no-lone-blocks
{
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
}
})
})
describe('workflow with auto name', () => {
it('should work with auto name pattern', async () => {
interface Database {
connect: () => Promise<void>
close: () => Promise<void>
}
const databaseConnectSpy = vi.fn()
const databaseCloseSpy = vi.fn()
function createDatabase(params: { lifecycle?: Lifecycle }): Database {
const database: Database = { connect: databaseConnectSpy, close: databaseCloseSpy }
params.lifecycle?.appHooks.onStop(async () => await database.close())
return database
}
interface WebSocketServer {
start: () => Promise<void>
stop: () => Promise<void>
}
const webSocketServerStartSpy = vi.fn()
const webSocketServerStopSpy = vi.fn()
async function createWebSocketServer(params: { database: Database, lifecycle?: Lifecycle }): Promise<WebSocketServer> {
await params.database.connect()
const server: WebSocketServer = { start: webSocketServerStartSpy, stop: webSocketServerStopSpy }
params.lifecycle?.appHooks.onStop(async () => await server.stop())
return server
}
const app = createContainer()
const database = provide(app, {
dependsOn: { lifecycle },
build: async ({ dependsOn }) => createDatabase(dependsOn),
})
const webSocketServer = provide(app, {
dependsOn: { database, lifecycle },
build: async ({ dependsOn }) => createWebSocketServer(dependsOn),
})
invoke(app, {
dependsOn: { webSocketServer },
callback: async ({ webSocketServer }) => await webSocketServer.start(),
})
await start(app)
await stop(app)
// eslint-disable-next-line no-lone-blocks
{
expect(databaseConnectSpy).toHaveBeenCalledTimes(1)
expect(databaseCloseSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStartSpy).toHaveBeenCalledTimes(1)
expect(webSocketServerStopSpy).toHaveBeenCalledTimes(1)
}
})
})
-296
View File
@@ -1,296 +0,0 @@
import type { Lifecycle, LifecycleTriggerable } from './builtin'
import type { Logger, LoggerOptions } from './logger'
import ErrorStackParser from 'error-stack-parser'
import { nanoid } from 'nanoid'
import { createDefaultLogger, createNoopLogger } from './logger'
export type DependencyMap = Record<string, any>
export interface Container {
providers: Map<string, ProvideOptionObject<any, any>>
instances: Map<string, any>
lifecycleHooks: Map<string, LifecycleTriggerable>
dependencyGraph: Map<string, string[]>
invocations: InvokeOptionObject<any>[]
logger: Logger
}
export type BuildContext<D extends DependencyMap | undefined = undefined> = {
container: Container
name: string
} & (D extends undefined ? { dependsOn?: unknown } : { dependsOn: D })
export type ResolveDependencyDeclaration<Deps extends Record<string, string | ProvidedKey<any, any, any>>> = {
[K in keyof Deps]: Deps[K] extends ProvidedKey<any, infer T, any> ? T : any
}
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 } })
export type ProvideOptionFunc<T, D extends DependencyMap | undefined = undefined> = (context: BuildContext<D>) => T | Promise<T>
export type ProvideOption<T, D extends DependencyMap | undefined = undefined> = ProvideOptionObject<T, D> | ProvideOptionFunc<T, D>
export interface ProvideOptionObjectWithKeys<T, Deps extends Record<string, string | ProvidedKey<any, any, any>>> {
build: (context: BuildContext<ResolveDependencyDeclaration<Deps>>) => T | Promise<T>
dependsOn: Deps
}
export type ProvideOptionWithKeys<T, Deps extends Record<string, string | ProvidedKey<any, any, any>>> = ProvideOptionObjectWithKeys<T, Deps>
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 } })
export type InvokeOptionFunc<D extends DependencyMap | undefined = undefined> = (dependencies: D) => void | Promise<void>
export type InvokeOption<D extends DependencyMap | undefined = undefined> = InvokeOptionObject<D> | InvokeOptionFunc<D>
export interface InvokeOptionObjectWithKeys<Deps extends Record<string, string | ProvidedKey<any, any, any>>> {
callback: (dependencies: ResolveDependencyDeclaration<Deps>) => void | Promise<void>
dependsOn: Deps
}
export type InvokeOptionWithKeys<Deps extends Record<string, string | ProvidedKey<any, any, any>>> = InvokeOptionObjectWithKeys<Deps>
export const lifecycle: ProvidedKey<'lifecycle', Lifecycle, undefined> = { key: 'lifecycle' }
export function createContainer(options?: LoggerOptions): Container {
const logger = options?.enabled === false
? createNoopLogger()
: (options?.logger ?? createDefaultLogger())
return {
providers: new Map(),
instances: new Map(),
lifecycleHooks: new Map(),
dependencyGraph: new Map(),
invocations: [],
logger,
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
export interface ProvidedKey<Key, T, D extends DependencyMap | undefined> {
key: Key
}
export function normalizeName(nameOrProvidedKey: string | ProvidedKey<any, any, any>): string {
if (typeof nameOrProvidedKey === 'object' && nameOrProvidedKey !== null && 'key' in nameOrProvidedKey) {
return (nameOrProvidedKey as ProvidedKey<any, any, any>).key as string
}
return nameOrProvidedKey as string
}
export function normalizeProvideOption<D extends DependencyMap | undefined, T = any, Key extends string = string>(nameOrOption: Key | ProvidedKey<Key, T, D> | ProvideOption<T, D> | ProvideOptionWithKeys<T, any>, option?: (ProvideOption<T, D> | ProvideOptionWithKeys<T, any>) & { autoNameStackIndex?: number }): ProvideOptionObject<T, D> {
if (typeof nameOrOption === 'string') {
if (option == null || (!('build' in option) && typeof option !== 'function')) {
throw new Error('When using provide(...) as named callback, the second argument must be either a valid ProvideOptionObject<T, D> or a ProvideOptionFunc<T, D>.')
}
if (typeof option === 'function') {
return { build: option } as ProvideOptionObject<T, D>
}
return option as ProvideOptionObject<T, D>
}
if (typeof nameOrOption === 'object' && 'key' in nameOrOption) {
if (option == null || (!('build' in option) && typeof option !== 'function')) {
throw new Error('When using provide(...) as typed ProvideOptionWithKeys<T, D, Key> callback, the second argument must be either a valid ProvideOptionObject<T, D> or a ProvideOptionFunc<T, D>.')
}
if (typeof option === 'function') {
return { build: option } as ProvideOptionObject<T, D>
}
return option as ProvideOptionObject<T, D>
}
if (typeof nameOrOption === 'function') {
return { build: nameOrOption } as ProvideOptionObject<T, D>
}
return nameOrOption as ProvideOptionObject<T, D>
}
export function provide<T, Key extends string, Deps extends Record<string, string | ProvidedKey<any, any, any>>>(container: Container, name: Key, option: ProvideOptionWithKeys<T, Deps>): ProvidedKey<Key, T, ResolveDependencyDeclaration<Deps>>
export function provide<T, Key extends string, Deps extends Record<string, string | ProvidedKey<any, any, any>>>(container: Container, option: ProvideOptionWithKeys<T, Deps> & { autoNameStackIndex?: number }): ProvidedKey<Key, T, ResolveDependencyDeclaration<Deps>>
export function provide<D extends DependencyMap | undefined, T = any, Key extends string = string>(container: Container, name: Key, option: ProvideOption<T, D>): ProvidedKey<Key, T, D>
export function provide<D extends DependencyMap | undefined, T = any, Key extends string = string>(container: Container, option: ProvideOption<T, D> & { autoNameStackIndex?: number }): ProvidedKey<Key, T, D>
export function provide<D extends DependencyMap | undefined, T = any, Key extends string = string>(container: Container, nameOrOption: Key | ProvideOption<T, D> | ProvideOptionWithKeys<T, any>, option?: (ProvideOption<T, D> | ProvideOptionWithKeys<T, any>) & { autoNameStackIndex?: number }): ProvidedKey<Key, T, D> {
const parentFile = ErrorStackParser.parse(new Error('providing'))[option?.autoNameStackIndex ?? 1]
const name = typeof nameOrOption === 'string'
? nameOrOption
: `${parentFile.fileName ?? `unknown-${nanoid()}`}:${parentFile.lineNumber ?? 0}:${parentFile.columnNumber ?? 0}`
const providerObject = normalizeProvideOption<D, T, Key>(nameOrOption, option) as unknown as ProvideOptionObject<any, any>
if (providerObject.dependsOn) {
const resolvedDependsOn: Record<string, string> = {}
for (const [key, value] of Object.entries(providerObject.dependsOn)) {
resolvedDependsOn[key] = normalizeName(value)
}
providerObject.dependsOn = resolvedDependsOn
}
container.providers.set(name, providerObject)
const dependencies = providerObject.dependsOn ? Object.values(providerObject.dependsOn) : []
container.logger.provide(name, dependencies)
container.dependencyGraph.set(name, dependencies)
return { key: name } as ProvidedKey<Key, T, D>
}
export function invoke<Deps extends Record<string, string | ProvidedKey<any, any, any>>>(container: Container, option: InvokeOptionWithKeys<Deps>): void
export function invoke<D extends DependencyMap>(container: Container, option: InvokeOption<D>): void
export function invoke<D extends DependencyMap>(container: Container, option: InvokeOption<D> | InvokeOptionWithKeys<any>): void {
const invocationObject = typeof option === 'function'
? { callback: option } as InvokeOptionObject<any>
: option as InvokeOptionObject<any>
if (invocationObject.dependsOn) {
const resolvedDependsOn: Record<string, string> = {}
for (const [key, value] of Object.entries(invocationObject.dependsOn)) {
resolvedDependsOn[key] = normalizeName(value)
}
invocationObject.dependsOn = resolvedDependsOn
}
const dependencies = invocationObject.dependsOn ? Object.values(invocationObject.dependsOn) : []
container.logger.invoke(dependencies)
container.invocations.push(invocationObject)
}
async function resolveInstance<T>(container: Container, name: string): Promise<T> {
if (container.instances.has(name)) {
return container.instances.get(name) as T
}
const provider = container.providers.get(name)
if (!provider) {
throw new Error(`No provider found for '${name}'`)
}
const resolvedDependencies: Record<string, any> = {}
let serviceLifecycle: any = null
if (provider.dependsOn) {
for (const [key, depName] of Object.entries(provider.dependsOn)) {
if (depName === 'lifecycle') {
// Create individual lifecycle instance for this service
const { buildLifecycle } = await import('./builtin')
serviceLifecycle = buildLifecycle()
resolvedDependencies[key] = serviceLifecycle
// Track this service's lifecycle
container.lifecycleHooks.set(name, serviceLifecycle)
}
else {
resolvedDependencies[key] = await resolveInstance(container, depName)
}
}
}
const context: BuildContext<typeof provider.dependsOn> = {
container,
dependsOn: resolvedDependencies,
name,
}
container.logger.beforeRun(name)
const startTime = performance.now()
const instance = await provider.build(context)
const duration = performance.now() - startTime
container.logger.run(name, duration)
container.instances.set(name, instance)
return instance as T
}
function topologicalSort(dependencyGraph: Map<string, string[]>): string[] {
const visited = new Set<string>()
const visiting = new Set<string>()
const result: string[] = []
function visit(node: string) {
if (visiting.has(node)) {
throw new Error(`Circular dependency detected involving '${node}'`)
}
if (visited.has(node)) {
return
}
visiting.add(node)
const dependencies = dependencyGraph.get(node) || []
for (const dep of dependencies) {
visit(dep)
}
visiting.delete(node)
visited.add(node)
result.push(node)
}
for (const node of dependencyGraph.keys()) {
if (!visited.has(node)) {
visit(node)
}
}
return result
}
export async function startLifecycleHooks(container: Container): Promise<void> {
const sortedServices = topologicalSort(container.dependencyGraph)
for (const serviceName of sortedServices) {
const lifecycle = container.lifecycleHooks.get(serviceName)
if (lifecycle?.emitOnStart) {
container.logger.hookOnStart(serviceName)
const startTime = performance.now()
await lifecycle.emitOnStart()
const duration = performance.now() - startTime
container.logger.hookOnStartComplete(serviceName, duration)
}
}
}
export async function stopLifecycleHooks(container: Container): Promise<void> {
const sortedServices = topologicalSort(container.dependencyGraph)
// Shutdown in reverse order
for (const serviceName of sortedServices.reverse()) {
const lifecycle = container.lifecycleHooks.get(serviceName)
if (lifecycle?.emitOnStop) {
container.logger.hookOnStop(serviceName)
const startTime = performance.now()
await lifecycle.emitOnStop()
const duration = performance.now() - startTime
container.logger.hookOnStopComplete(serviceName, duration)
}
}
}
export async function start(container: Container): Promise<void> {
await startLifecycleHooks(container)
for (const invocation of container.invocations) {
const resolvedDependencies: Record<string, any> = {}
if (invocation.dependsOn) {
for (const [key, depName] of Object.entries(invocation.dependsOn)) {
resolvedDependencies[key] = await resolveInstance(container, normalizeName(depName))
}
}
await invocation.callback(resolvedDependencies)
}
container.logger.running()
}
export async function stop(container: Container): Promise<void> {
await stopLifecycleHooks(container)
}
-35
View File
@@ -1,35 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": [
"DOM",
"ESNext"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": [
"vitest",
"vite/client"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts"
],
"exclude": [
"dist",
"node_modules"
]
}
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
'./index': 'src/index.ts',
},
dts: true,
sourcemap: true,
unused: true,
})
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+36 -18
View File
@@ -302,9 +302,6 @@ importers:
'@proj-airi/i18n':
specifier: workspace:^
version: link:../../packages/i18n
'@proj-airi/injecta':
specifier: workspace:*
version: link:../../packages/injecta
'@proj-airi/server-runtime':
specifier: workspace:^
version: link:../../packages/server-runtime
@@ -413,6 +410,9 @@ importers:
h3:
specifier: 2.0.1-rc.5
version: 2.0.1-rc.5(crossws@0.4.1(srvx@0.9.4))
injeca:
specifier: ^0.1.1
version: 0.1.1(@guiiai/logg@1.1.0)(error-stack-parser@2.1.4)(nanoid@5.1.6)
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -1192,18 +1192,6 @@ importers:
specifier: ^3.0.7
version: 3.0.7(@nuxt/kit@4.0.3(magicast@0.3.5))(@nuxt/schema@4.0.3)(astro@5.10.1(@netlify/blobs@9.1.2)(@types/node@24.10.0)(db0@0.3.2(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.7.0)(jiti@2.5.1)(less@4.4.2)(lightningcss@1.30.2)(rollup@4.52.5)(terser@5.43.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(esbuild@0.25.9)(rolldown@1.0.0-beta.45)(rollup@4.52.5)(vite@7.1.12(@types/node@24.10.0)(jiti@2.5.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))
packages/injecta:
devDependencies:
'@guiiai/logg':
specifier: 'catalog:'
version: 1.1.0
error-stack-parser:
specifier: ^2.1.4
version: 2.1.4
nanoid:
specifier: ^5.1.6
version: 5.1.6
packages/memory-pgvector:
dependencies:
'@guiiai/logg':
@@ -1879,7 +1867,7 @@ importers:
version: rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1)
vite-plugin-vue-devtools:
specifier: ^8.0.3
version: 8.0.3(@nuxt/kit@4.0.3(magicast@0.3.5))(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
version: 8.0.3(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
vue-router:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.22(typescript@5.9.3))
@@ -1913,7 +1901,7 @@ importers:
version: rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1)
vite-plugin-vue-devtools:
specifier: ^8.0.3
version: 8.0.3(@nuxt/kit@4.0.3(magicast@0.3.5))(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
version: 8.0.3(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
vue-router:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.22(typescript@5.9.3))
@@ -11012,6 +11000,16 @@ packages:
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
injeca@0.1.1:
resolution: {integrity: sha512-K0vAFK/DD6X9LaNu79hUPeVx4Qe1FgeMVEKNdjbEiLS/XhiahyPk/L0f8iIe6MdW3Fr6w0yay+zcKEbVminxnw==}
peerDependencies:
'@guiiai/logg': '>= 1'
error-stack-parser: ^2.1.4
nanoid: '>=5'
peerDependenciesMeta:
'@guiiai/logg':
optional: true
ioredis@5.7.0:
resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==}
engines: {node: '>=12.22.0'}
@@ -27240,6 +27238,13 @@ snapshots:
ini@4.1.3: {}
injeca@0.1.1(@guiiai/logg@1.1.0)(error-stack-parser@2.1.4)(nanoid@5.1.6):
dependencies:
error-stack-parser: 2.1.4
nanoid: 5.1.6
optionalDependencies:
'@guiiai/logg': 1.1.0
ioredis@5.7.0:
dependencies:
'@ioredis/commands': 1.3.0
@@ -33448,7 +33453,6 @@ snapshots:
'@nuxt/kit': 3.18.1(magicast@0.3.5)
transitivePeerDependencies:
- supports-color
optional: true
vite-plugin-inspect@11.3.3(@nuxt/kit@3.18.1(magicast@0.3.5))(vite@6.4.1(@types/node@24.10.0)(jiti@2.5.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
@@ -33527,6 +33531,20 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-devtools@8.0.3(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.3(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
'@vue/devtools-kit': 8.0.3
'@vue/devtools-shared': 8.0.3
sirv: 3.0.2
vite: rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1)
vite-plugin-inspect: 11.3.3(@nuxt/kit@3.18.1(magicast@0.3.5))(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))
vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.1.20(@types/node@24.10.0)(esbuild@0.25.9)(jiti@2.5.1)(less@4.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@babel/core': 7.28.3