@forinda/kickjs-multi-tenant 3.1.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,57 +1,38 @@
1
1
  # @forinda/kickjs-multi-tenant
2
2
 
3
- Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing.
3
+ Multi-tenancy for KickJS — tenant resolution from header/subdomain/path/query/custom, request-scoped DI via AsyncLocalStorage, and per-tenant DB routing through the `prisma` / `drizzle` tenant adapters.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- # Using the KickJS CLI (recommended)
9
8
  kick add multi-tenant
10
-
11
- # Manual install
12
- pnpm add @forinda/kickjs-multi-tenant
13
9
  ```
14
10
 
15
- ## Features
16
-
17
- - `TenantAdapter` — lifecycle adapter that resolves tenant from requests
18
- - `TENANT_CONTEXT` token for injecting tenant info via DI (request-scoped via AsyncLocalStorage)
19
- - `getCurrentTenant()` — functional helper for use outside DI
20
- - Pluggable resolution strategies: header, subdomain, path, query, or custom
21
- - Per-tenant database switching: database, schema, or discriminator modes
22
- - `TENANT_DB` token for injecting per-tenant database connections
23
- - Integration with `@forinda/kickjs-auth` for tenant-scoped RBAC
24
-
25
11
  ## Quick Example
26
12
 
27
- ```typescript
13
+ ```ts
14
+ import { bootstrap } from '@forinda/kickjs'
28
15
  import { TenantAdapter, TENANT_CONTEXT, type TenantInfo } from '@forinda/kickjs-multi-tenant'
29
16
  import { Inject, Service } from '@forinda/kickjs'
17
+ import { modules } from './modules'
30
18
 
31
- bootstrap({
19
+ export const app = await bootstrap({
32
20
  modules,
33
- adapters: [
34
- new TenantAdapter({
35
- strategy: 'header',
36
- headerName: 'X-Tenant-ID',
37
- }),
38
- ],
21
+ adapters: [TenantAdapter({ strategy: 'header', headerName: 'X-Tenant-ID' })],
39
22
  })
40
23
 
41
- // Access tenant in any service
42
24
  @Service()
43
25
  class DataService {
44
- @Inject(TENANT_CONTEXT) private tenant!: TenantInfo
45
-
46
- async getData() {
47
- return this.repo.findByTenant(this.tenant.id)
48
- }
26
+ constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}
27
+ getData() { return this.repo.findByTenant(this.tenant.id) }
49
28
  }
50
29
  ```
51
30
 
31
+ For sharded / multi-realm setups use `TenantAdapter.scoped('eu', { ... })` — name composes as `TenantAdapter:eu` so `dependsOn` lookups stay unambiguous.
32
+
52
33
  ## Documentation
53
34
 
54
- [Full documentation](https://forinda.github.io/kick-js/)
35
+ [forinda.github.io/kick-js/guide/multi-tenancy](https://forinda.github.io/kick-js/guide/multi-tenancy)
55
36
 
56
37
  ## License
57
38
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { AdapterContext, AdapterMiddleware, AppAdapter } from "@forinda/kickjs";
2
+ import * as _$_forinda_kickjs0 from "@forinda/kickjs";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
 
5
5
  //#region src/database.d.ts
@@ -52,8 +52,6 @@ type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | Discrimi
52
52
  declare const TENANT_DB: unique symbol;
53
53
  //#endregion
54
54
  //#region src/types.d.ts
55
- /** DI token for the current tenant context */
56
- declare const TENANT_CONTEXT: unique symbol;
57
55
  /** Tenant information resolved from the request */
58
56
  interface TenantInfo {
59
57
  /** Unique tenant identifier */
@@ -63,6 +61,14 @@ interface TenantInfo {
63
61
  /** Optional tenant-specific config/metadata */
64
62
  metadata?: Record<string, any>;
65
63
  }
64
+ /**
65
+ * DI token for the current tenant context.
66
+ *
67
+ * Backed by AsyncLocalStorage — registered as `Scope.TRANSIENT` so each
68
+ * resolution returns the request-scoped tenant. Throws if resolved
69
+ * outside a request that ran the tenant middleware.
70
+ */
71
+ declare const TENANT_CONTEXT: _$_forinda_kickjs0.InjectionToken<TenantInfo>;
66
72
  /** Strategy for resolving the tenant from a request */
67
73
  type TenantResolutionStrategy = 'header' | 'subdomain' | 'path' | 'query' | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>);
68
74
  interface MultiTenantOptions {
@@ -100,8 +106,8 @@ interface MultiTenantOptions {
100
106
  /**
101
107
  * Multi-tenancy adapter for KickJS.
102
108
  *
103
- * Resolves the tenant from each request and makes it available
104
- * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.
109
+ * Resolves the tenant from each request and makes it available via DI
110
+ * (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.
105
111
  *
106
112
  * @example
107
113
  * ```ts
@@ -110,7 +116,7 @@ interface MultiTenantOptions {
110
116
  * bootstrap({
111
117
  * modules,
112
118
  * adapters: [
113
- * new TenantAdapter({
119
+ * TenantAdapter({
114
120
  * strategy: 'header',
115
121
  * onTenantResolved: async (tenant) => {
116
122
  * // Load tenant config from DB, validate, etc.
@@ -125,17 +131,19 @@ interface MultiTenantOptions {
125
131
  * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}
126
132
  * }
127
133
  * ```
134
+ *
135
+ * Multiple shards or independent tenant pipelines? Use `.scoped()` for
136
+ * a per-shard instance — each one gets its own `name` (e.g.
137
+ * `TenantAdapter:eu`) so `dependsOn` lookups stay unambiguous:
138
+ *
139
+ * ```ts
140
+ * adapters: [
141
+ * TenantAdapter.scoped('eu', { strategy: 'header', headerName: 'x-eu-tenant' }),
142
+ * TenantAdapter.scoped('us', { strategy: 'header', headerName: 'x-us-tenant' }),
143
+ * ]
144
+ * ```
128
145
  */
129
- declare class TenantAdapter implements AppAdapter {
130
- name: string;
131
- private options;
132
- constructor(options?: MultiTenantOptions);
133
- middleware(): AdapterMiddleware[];
134
- beforeStart({
135
- container
136
- }: AdapterContext): void;
137
- private resolveTenant;
138
- }
146
+ declare const TenantAdapter: _$_forinda_kickjs0.AdapterFactory<MultiTenantOptions, unknown>;
139
147
  //#endregion
140
148
  //#region src/tenant.context.d.ts
141
149
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/database.ts","../src/types.ts","../src/tenant.adapter.ts","../src/tenant.context.ts"],"mappings":";;;;;;;;;AASA;;;;UAAiB,sBAAA;EACf,IAAA;EACA,IAAA;EACA,QAAA;EACA,IAAA;EACA,QAAA;AAAA;AAAA,UAGe,uBAAA;EACf,IAAA;;EAEA,OAAA,GAAU,QAAA,aAAqB,sBAAA,GAAyB,OAAA,CAAQ,sBAAA;EAAA;EAEhE,IAAA;IAAS,GAAA;IAAc,GAAA;IAAc,WAAA;EAAA;EAF3B;EAIV,KAAA;IAAU,GAAA;EAAA;AAAA;AAAA,UAGK,qBAAA;EACf,IAAA;EANqC;EAQrC,UAAA;EANU;EAQV,cAAA;AAAA;AAAA,UAGe,mBAAA;EACf,IAAA;;EAEA,UAAA;EAVA;EAYA,MAAA;AAAA;AAAA,KAGU,cAAA,GAAiB,uBAAA,GAA0B,qBAAA,GAAwB,mBAAA;;cAGlE,SAAA;;;;cC7CA,cAAA;;UAGI,UAAA;EDKA;ECHf,EAAA;;EAEA,IAAA;EDEA;ECAA,QAAA,GAAW,MAAA;AAAA;;KAID,wBAAA,iDAKN,GAAA,UAAa,UAAA,UAAoB,OAAA,CAAQ,UAAA;AAAA,UAE9B,kBAAA;EDPP;AAGV;;;;;;;ECaE,QAAA,GAAW,wBAAA;EDZX;ECeA,UAAA;EDbU;ECgBV,UAAA;EDhBwD;;;;ECsBxD,gBAAA,IAAoB,MAAA,EAAQ,UAAA,EAAY,GAAA,iBAAoB,OAAA;EDpBvB;ECuBrC,QAAA;EDrBU;ECwBV,aAAA;EDxBa;AAGf;;;;EC4BE,QAAA,GAbmE,cAAA;AAAA;;;;;ADjCrE;;;;;;;;;;;AAQA;;;;;;;;;;;;;;;;cE8Ba,aAAA,YAAyB,UAAA;EACpC,IAAA;EAAA,QACQ,OAAA;cAKI,OAAA,GAAS,kBAAA;EAUrB,UAAA,CAAA,GAAc,iBAAA;EAuCd,WAAA,CAAA;IAAc;EAAA,GAAa,cAAA;EAAA,QAqBb,aAAA;AAAA;;;;;;;;;;;AF3GhB;;;;;;;;iBGQgB,gBAAA,CAAA,GAAoB,UAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/database.ts","../src/types.ts","../src/tenant.adapter.ts","../src/tenant.context.ts"],"mappings":";;;;;;;;;AASA;;;;UAAiB,sBAAA;EACf,IAAA;EACA,IAAA;EACA,QAAA;EACA,IAAA;EACA,QAAA;AAAA;AAAA,UAGe,uBAAA;EACf,IAAA;;EAEA,OAAA,GAAU,QAAA,aAAqB,sBAAA,GAAyB,OAAA,CAAQ,sBAAA;EAAA;EAEhE,IAAA;IAAS,GAAA;IAAc,GAAA;IAAc,WAAA;EAAA;EAF3B;EAIV,KAAA;IAAU,GAAA;EAAA;AAAA;AAAA,UAGK,qBAAA;EACf,IAAA;EANqC;EAQrC,UAAA;EANU;EAQV,cAAA;AAAA;AAAA,UAGe,mBAAA;EACf,IAAA;;EAEA,UAAA;EAVA;EAYA,MAAA;AAAA;AAAA,KAGU,cAAA,GAAiB,uBAAA,GAA0B,qBAAA,GAAwB,mBAAA;;cAGlE,SAAA;;;;UC3CI,UAAA;;EAEf,EAAA;EDIe;ECFf,IAAA;;EAEA,QAAA,GAAW,MAAA;AAAA;;;;;;;ADQb;cCEa,cAAA,EAAc,kBAAA,CAAA,cAAA,CAAA,UAAA;;KAGf,wBAAA,iDAKN,GAAA,UAAa,UAAA,UAAoB,OAAA,CAAQ,UAAA;AAAA,UAE9B,kBAAA;EDTyC;;;;;;;;ECkBxD,QAAA,GAAW,wBAAA;EDhBX;ECmBA,UAAA;EDnBuB;ECsBvB,UAAA;EDpBA;;;;EC0BA,gBAAA,IAAoB,MAAA,EAAQ,UAAA,EAAY,GAAA,iBAAoB,OAAA;EDvBxB;EC0BpC,QAAA;ED1BoC;EC6BpC,aAAA;ED1BA;;;;AAKF;EC4BE,QAAA,GAbmE,cAAA;AAAA;;;;;;ADzCrE;;;;;;;;;;;AAQA;;;;;;;;;;;;;;;;;;;;;;AAUA;;;;cEqBa,aAAA,EAAa,kBAAA,CAAA,cAAA,CAAA,kBAAA;;;;;;;;;;;AF/B1B;;;;;;;;iBGQgB,gBAAA,CAAA,GAAoB,UAAA"}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-multi-tenant v3.1.3
2
+ * @forinda/kickjs-multi-tenant v4.0.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -8,11 +8,18 @@
8
8
  *
9
9
  * @license MIT
10
10
  */
11
- import { Logger, Scope } from "@forinda/kickjs";
11
+ import { Logger, Scope, createToken, defineAdapter } from "@forinda/kickjs";
12
+ import { PROTOCOL_VERSION } from "@forinda/kickjs-devtools-kit";
12
13
  import { AsyncLocalStorage } from "node:async_hooks";
13
14
  //#region src/types.ts
14
- /** DI token for the current tenant context */
15
- const TENANT_CONTEXT = Symbol("TenantContext");
15
+ /**
16
+ * DI token for the current tenant context.
17
+ *
18
+ * Backed by AsyncLocalStorage — registered as `Scope.TRANSIENT` so each
19
+ * resolution returns the request-scoped tenant. Throws if resolved
20
+ * outside a request that ran the tenant middleware.
21
+ */
22
+ const TENANT_CONTEXT = createToken("kick/tenant/Context");
16
23
  //#endregion
17
24
  //#region src/tenant.context.ts
18
25
  /**
@@ -45,8 +52,8 @@ const log = Logger.for("MultiTenant");
45
52
  /**
46
53
  * Multi-tenancy adapter for KickJS.
47
54
  *
48
- * Resolves the tenant from each request and makes it available
49
- * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.
55
+ * Resolves the tenant from each request and makes it available via DI
56
+ * (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.
50
57
  *
51
58
  * @example
52
59
  * ```ts
@@ -55,7 +62,7 @@ const log = Logger.for("MultiTenant");
55
62
  * bootstrap({
56
63
  * modules,
57
64
  * adapters: [
58
- * new TenantAdapter({
65
+ * TenantAdapter({
59
66
  * strategy: 'header',
60
67
  * onTenantResolved: async (tenant) => {
61
68
  * // Load tenant config from DB, validate, etc.
@@ -70,72 +77,108 @@ const log = Logger.for("MultiTenant");
70
77
  * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}
71
78
  * }
72
79
  * ```
80
+ *
81
+ * Multiple shards or independent tenant pipelines? Use `.scoped()` for
82
+ * a per-shard instance — each one gets its own `name` (e.g.
83
+ * `TenantAdapter:eu`) so `dependsOn` lookups stay unambiguous:
84
+ *
85
+ * ```ts
86
+ * adapters: [
87
+ * TenantAdapter.scoped('eu', { strategy: 'header', headerName: 'x-eu-tenant' }),
88
+ * TenantAdapter.scoped('us', { strategy: 'header', headerName: 'x-us-tenant' }),
89
+ * ]
90
+ * ```
73
91
  */
74
- var TenantAdapter = class {
75
- name = "TenantAdapter";
76
- options;
77
- constructor(options = {}) {
78
- this.options = {
79
- strategy: options.strategy ?? "header",
80
- required: options.required ?? true,
81
- headerName: options.headerName ?? "x-tenant-id",
82
- queryParam: options.queryParam ?? "tenantId",
83
- ...options
84
- };
85
- }
86
- middleware() {
87
- return [{
88
- handler: async (req, res, next) => {
89
- if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) return next();
90
- const tenant = await this.resolveTenant(req);
91
- if (!tenant) {
92
- if (this.options.required) {
93
- res.status(403).json({ message: "Tenant not found. Provide a valid tenant identifier." });
94
- return;
92
+ const TenantAdapter = defineAdapter({
93
+ name: "TenantAdapter",
94
+ defaults: {
95
+ strategy: "header",
96
+ required: true,
97
+ headerName: "x-tenant-id",
98
+ queryParam: "tenantId"
99
+ },
100
+ build: (options) => {
101
+ let tenantsResolved = 0;
102
+ let tenantsRejected = 0;
103
+ return {
104
+ introspect() {
105
+ return {
106
+ protocolVersion: PROTOCOL_VERSION,
107
+ name: "TenantAdapter",
108
+ kind: "adapter",
109
+ state: {
110
+ strategy: typeof options.strategy === "function" ? "custom" : options.strategy ?? null,
111
+ required: options.required ?? true,
112
+ headerName: options.headerName ?? null,
113
+ queryParam: options.queryParam ?? null
114
+ },
115
+ tokens: {
116
+ provides: ["kick/tenant/Context"],
117
+ requires: []
118
+ },
119
+ metrics: {
120
+ tenantsResolved,
121
+ tenantsRejected
95
122
  }
96
- return next();
97
- }
98
- req.tenant = tenant;
99
- if (this.options.onTenantResolved) await this.options.onTenantResolved(tenant, req);
100
- tenantStorage.run(tenant, () => next());
123
+ };
101
124
  },
102
- phase: "beforeGlobal"
103
- }];
104
- }
105
- beforeStart({ container }) {
106
- container.registerFactory(TENANT_CONTEXT, () => {
107
- const tenant = getCurrentTenant();
108
- if (!tenant) throw new Error("TENANT_CONTEXT resolved outside request scope. Ensure TenantAdapter middleware is active and the code runs within a request.");
109
- return tenant;
110
- }, Scope.TRANSIENT);
111
- log.info(`Tenant resolution: ${typeof this.options.strategy === "function" ? "custom" : this.options.strategy}`);
112
- }
113
- async resolveTenant(req) {
114
- const strategy = this.options.strategy;
115
- if (typeof strategy === "function") return strategy(req);
116
- switch (strategy) {
117
- case "header": {
118
- const tenantId = req.get(this.options.headerName);
119
- return tenantId ? { id: tenantId } : null;
120
- }
121
- case "subdomain": {
122
- const parts = req.hostname.split(".");
123
- if (parts.length >= 3) return { id: parts[0] };
124
- return null;
125
- }
126
- case "path": {
127
- const segments = req.path.split("/").filter(Boolean);
128
- if (segments.length > 0) return { id: segments[0] };
129
- return null;
130
- }
131
- case "query": {
132
- const tenantId = req.query[this.options.queryParam];
133
- return tenantId ? { id: tenantId } : null;
125
+ middleware() {
126
+ return [{
127
+ handler: async (req, res, next) => {
128
+ if (options.excludeRoutes?.some((r) => req.path.startsWith(r))) return next();
129
+ const tenant = await resolveTenant(req, options);
130
+ if (!tenant) {
131
+ if (options.required) {
132
+ tenantsRejected++;
133
+ res.status(403).json({ message: "Tenant not found. Provide a valid tenant identifier." });
134
+ return;
135
+ }
136
+ return next();
137
+ }
138
+ tenantsResolved++;
139
+ req.tenant = tenant;
140
+ if (options.onTenantResolved) await options.onTenantResolved(tenant, req);
141
+ tenantStorage.run(tenant, () => next());
142
+ },
143
+ phase: "beforeGlobal"
144
+ }];
145
+ },
146
+ beforeStart({ container }) {
147
+ container.registerFactory(TENANT_CONTEXT, () => {
148
+ const tenant = getCurrentTenant();
149
+ if (!tenant) throw new Error("TENANT_CONTEXT resolved outside request scope. Ensure TenantAdapter middleware is active and the code runs within a request.");
150
+ return tenant;
151
+ }, Scope.TRANSIENT);
152
+ log.info(`Tenant resolution: ${typeof options.strategy === "function" ? "custom" : options.strategy}`);
134
153
  }
135
- default: return null;
154
+ };
155
+ }
156
+ });
157
+ async function resolveTenant(req, options) {
158
+ const strategy = options.strategy;
159
+ if (typeof strategy === "function") return strategy(req);
160
+ switch (strategy) {
161
+ case "header": {
162
+ const tenantId = req.get(options.headerName);
163
+ return tenantId ? { id: tenantId } : null;
164
+ }
165
+ case "subdomain": {
166
+ const parts = req.hostname.split(".");
167
+ if (parts.length >= 3) return { id: parts[0] };
168
+ return null;
169
+ }
170
+ case "path": {
171
+ const segments = req.path.split("/").filter(Boolean);
172
+ if (segments.length > 0) return { id: segments[0] };
173
+ return null;
136
174
  }
175
+ case "query": {
176
+ const tenantId = req.query[options.queryParam];
177
+ return tenantId ? { id: tenantId } : null;
178
+ }
179
+ default: return null;
137
180
  }
138
- };
181
+ }
139
182
  //#endregion
140
183
  //#region src/database.ts
141
184
  /** DI token for the current tenant's database connection */
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.context.ts","../src/tenant.adapter.ts","../src/database.ts"],"sourcesContent":["/** DI token for the current tenant context */\nexport const TENANT_CONTEXT = Symbol('TenantContext')\n\n/** Tenant information resolved from the request */\nexport interface TenantInfo {\n /** Unique tenant identifier */\n id: string\n /** Optional tenant name */\n name?: string\n /** Optional tenant-specific config/metadata */\n metadata?: Record<string, any>\n}\n\n/** Strategy for resolving the tenant from a request */\nexport type TenantResolutionStrategy =\n | 'header'\n | 'subdomain'\n | 'path'\n | 'query'\n | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>)\n\nexport interface MultiTenantOptions {\n /**\n * How to resolve the tenant from the request.\n * - 'header' — reads X-Tenant-ID header (default)\n * - 'subdomain' — extracts from subdomain (tenant.example.com)\n * - 'path' — extracts from first path segment (/tenant-id/...)\n * - 'query' — reads ?tenantId= query param\n * - function — custom resolver\n */\n strategy?: TenantResolutionStrategy\n\n /** Header name when strategy is 'header' (default: 'x-tenant-id') */\n headerName?: string\n\n /** Query param name when strategy is 'query' (default: 'tenantId') */\n queryParam?: string\n\n /**\n * Called after tenant is resolved. Use for validation, loading tenant\n * config from DB, or rejecting unknown tenants.\n */\n onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>\n\n /** Return a 403 if no tenant can be resolved (default: true) */\n required?: boolean\n\n /** Routes to skip tenant resolution (e.g., health checks) */\n excludeRoutes?: string[]\n\n /**\n * Per-tenant database switching configuration.\n * When set, a TENANT_DB DI token is registered that resolves to\n * the current tenant's database connection.\n */\n database?: import('./database').TenantDatabase\n}\n","import { AsyncLocalStorage } from 'node:async_hooks'\nimport type { TenantInfo } from './types'\n\n/**\n * AsyncLocalStorage instance that holds the current request's tenant.\n * Used internally by TenantAdapter to make tenant resolution request-scoped.\n */\nexport const tenantStorage = new AsyncLocalStorage<TenantInfo>()\n\n/**\n * Get the current request's tenant from AsyncLocalStorage.\n *\n * Returns `undefined` when called outside a request scope (e.g.,\n * during startup, in a background job, or in tests without setup).\n *\n * @example\n * ```ts\n * import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'\n *\n * function logForTenant(message: string) {\n * const tenant = getCurrentTenant()\n * console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)\n * }\n * ```\n */\nexport function getCurrentTenant(): TenantInfo | undefined {\n return tenantStorage.getStore()\n}\n","import {\n Logger,\n type AppAdapter,\n type AdapterContext,\n type AdapterMiddleware,\n Scope,\n} from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport {\n TENANT_CONTEXT,\n type TenantInfo,\n type MultiTenantOptions,\n type TenantResolutionStrategy,\n} from './types'\nimport { tenantStorage, getCurrentTenant } from './tenant.context'\n\nconst log = Logger.for('MultiTenant')\n\n/**\n * Multi-tenancy adapter for KickJS.\n *\n * Resolves the tenant from each request and makes it available\n * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.\n *\n * @example\n * ```ts\n * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * new TenantAdapter({\n * strategy: 'header',\n * onTenantResolved: async (tenant) => {\n * // Load tenant config from DB, validate, etc.\n * },\n * }),\n * ],\n * })\n *\n * // In a service:\n * @Service()\n * class UserService {\n * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}\n * }\n * ```\n */\nexport class TenantAdapter implements AppAdapter {\n name = 'TenantAdapter'\n private options: Required<\n Pick<MultiTenantOptions, 'strategy' | 'required' | 'headerName' | 'queryParam'>\n > &\n MultiTenantOptions\n\n constructor(options: MultiTenantOptions = {}) {\n this.options = {\n strategy: options.strategy ?? 'header',\n required: options.required ?? true,\n headerName: options.headerName ?? 'x-tenant-id',\n queryParam: options.queryParam ?? 'tenantId',\n ...options,\n }\n }\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n // Skip excluded routes\n if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await this.resolveTenant(req)\n\n if (!tenant) {\n if (this.options.required) {\n res\n .status(403)\n .json({ message: 'Tenant not found. Provide a valid tenant identifier.' })\n return\n }\n return next()\n }\n\n // Attach to request\n ;(req as any).tenant = tenant\n\n // Call hook\n if (this.options.onTenantResolved) {\n await this.options.onTenantResolved(tenant, req)\n }\n\n // Wrap the rest of the request in AsyncLocalStorage so\n // @Inject(TENANT_CONTEXT) and getCurrentTenant() return\n // the correct tenant for this request.\n tenantStorage.run(tenant, () => next())\n },\n phase: 'beforeGlobal',\n },\n ]\n }\n\n beforeStart({ container }: AdapterContext): void {\n // Register as TRANSIENT so each resolution reads from AsyncLocalStorage\n container.registerFactory(\n TENANT_CONTEXT,\n () => {\n const tenant = getCurrentTenant()\n if (!tenant) {\n throw new Error(\n 'TENANT_CONTEXT resolved outside request scope. ' +\n 'Ensure TenantAdapter middleware is active and the code runs within a request.',\n )\n }\n return tenant\n },\n Scope.TRANSIENT,\n )\n log.info(\n `Tenant resolution: ${typeof this.options.strategy === 'function' ? 'custom' : this.options.strategy}`,\n )\n }\n\n private async resolveTenant(req: Request): Promise<TenantInfo | null> {\n const strategy = this.options.strategy\n\n if (typeof strategy === 'function') {\n return strategy(req)\n }\n\n switch (strategy) {\n case 'header': {\n const tenantId = req.get(this.options.headerName)\n return tenantId ? { id: tenantId } : null\n }\n case 'subdomain': {\n const host = req.hostname\n const parts = host.split('.')\n if (parts.length >= 3) {\n return { id: parts[0] }\n }\n return null\n }\n case 'path': {\n const segments = req.path.split('/').filter(Boolean)\n if (segments.length > 0) {\n return { id: segments[0] }\n }\n return null\n }\n case 'query': {\n const tenantId = req.query[this.options.queryParam] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\n }\n }\n}\n","/**\n * Per-tenant database switching configuration.\n *\n * Three isolation modes, from strongest to weakest:\n * - `database` — each tenant has its own database\n * - `schema` — shared database, separate schemas (PostgreSQL)\n * - `discriminator` — shared tables with a tenant_id column\n */\n\nexport interface DatabaseConnectionInfo {\n host: string\n port?: number\n database: string\n user: string\n password: string\n}\n\nexport interface DatabasePerTenantConfig {\n mode: 'database'\n /** Resolve connection info for a tenant */\n resolve: (tenantId: string) => DatabaseConnectionInfo | Promise<DatabaseConnectionInfo>\n /** Connection pool settings */\n pool?: { min?: number; max?: number; idleTimeout?: number }\n /** Cache resolved connections (default TTL: 300_000ms = 5 min) */\n cache?: { ttl?: number }\n}\n\nexport interface SchemaPerTenantConfig {\n mode: 'schema'\n /** Base connection URL (shared database) */\n connection: string\n /** Schema name template. `${tenantId}` is replaced at runtime. Default: `'tenant_${tenantId}'` */\n schemaTemplate?: string\n}\n\nexport interface DiscriminatorConfig {\n mode: 'discriminator'\n /** Base connection URL (shared everything) */\n connection: string\n /** Column name used to scope queries. Default: `'tenant_id'` */\n column?: string\n}\n\nexport type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig\n\n/** DI token for the current tenant's database connection */\nexport const TENANT_DB = Symbol('TenantDB')\n"],"mappings":";;;;;;;;;;;;;;AACA,MAAa,iBAAiB,OAAO,gBAAgB;;;;;;;ACMrD,MAAa,gBAAgB,IAAI,mBAA+B;;;;;;;;;;;;;;;;;AAkBhE,SAAgB,mBAA2C;AACzD,QAAO,cAAc,UAAU;;;;ACVjC,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BrC,IAAa,gBAAb,MAAiD;CAC/C,OAAO;CACP;CAKA,YAAY,UAA8B,EAAE,EAAE;AAC5C,OAAK,UAAU;GACb,UAAU,QAAQ,YAAY;GAC9B,UAAU,QAAQ,YAAY;GAC9B,YAAY,QAAQ,cAAc;GAClC,YAAY,QAAQ,cAAc;GAClC,GAAG;GACJ;;CAGH,aAAkC;AAChC,SAAO,CACL;GACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAElE,QAAI,KAAK,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CACjE,QAAO,MAAM;IAGf,MAAM,SAAS,MAAM,KAAK,cAAc,IAAI;AAE5C,QAAI,CAAC,QAAQ;AACX,SAAI,KAAK,QAAQ,UAAU;AACzB,UACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,YAAO,MAAM;;AAIb,QAAY,SAAS;AAGvB,QAAI,KAAK,QAAQ,iBACf,OAAM,KAAK,QAAQ,iBAAiB,QAAQ,IAAI;AAMlD,kBAAc,IAAI,cAAc,MAAM,CAAC;;GAEzC,OAAO;GACR,CACF;;CAGH,YAAY,EAAE,aAAmC;AAE/C,YAAU,gBACR,sBACM;GACJ,MAAM,SAAS,kBAAkB;AACjC,OAAI,CAAC,OACH,OAAM,IAAI,MACR,+HAED;AAEH,UAAO;KAET,MAAM,UACP;AACD,MAAI,KACF,sBAAsB,OAAO,KAAK,QAAQ,aAAa,aAAa,WAAW,KAAK,QAAQ,WAC7F;;CAGH,MAAc,cAAc,KAA0C;EACpE,MAAM,WAAW,KAAK,QAAQ;AAE9B,MAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,UAAQ,UAAR;GACE,KAAK,UAAU;IACb,MAAM,WAAW,IAAI,IAAI,KAAK,QAAQ,WAAW;AACjD,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,KAAK,aAAa;IAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,QAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,WAAO;;GAET,KAAK,QAAQ;IACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,QAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,WAAO;;GAET,KAAK,SAAS;IACZ,MAAM,WAAW,IAAI,MAAM,KAAK,QAAQ;AACxC,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,QACE,QAAO;;;;;;;AC9Gf,MAAa,YAAY,OAAO,WAAW"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.context.ts","../src/tenant.adapter.ts","../src/database.ts"],"sourcesContent":["import { createToken } from '@forinda/kickjs'\n\n/** Tenant information resolved from the request */\nexport interface TenantInfo {\n /** Unique tenant identifier */\n id: string\n /** Optional tenant name */\n name?: string\n /** Optional tenant-specific config/metadata */\n metadata?: Record<string, any>\n}\n\n/**\n * DI token for the current tenant context.\n *\n * Backed by AsyncLocalStorage — registered as `Scope.TRANSIENT` so each\n * resolution returns the request-scoped tenant. Throws if resolved\n * outside a request that ran the tenant middleware.\n */\nexport const TENANT_CONTEXT = createToken<TenantInfo>('kick/tenant/Context')\n\n/** Strategy for resolving the tenant from a request */\nexport type TenantResolutionStrategy =\n | 'header'\n | 'subdomain'\n | 'path'\n | 'query'\n | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>)\n\nexport interface MultiTenantOptions {\n /**\n * How to resolve the tenant from the request.\n * - 'header' — reads X-Tenant-ID header (default)\n * - 'subdomain' — extracts from subdomain (tenant.example.com)\n * - 'path' — extracts from first path segment (/tenant-id/...)\n * - 'query' — reads ?tenantId= query param\n * - function — custom resolver\n */\n strategy?: TenantResolutionStrategy\n\n /** Header name when strategy is 'header' (default: 'x-tenant-id') */\n headerName?: string\n\n /** Query param name when strategy is 'query' (default: 'tenantId') */\n queryParam?: string\n\n /**\n * Called after tenant is resolved. Use for validation, loading tenant\n * config from DB, or rejecting unknown tenants.\n */\n onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>\n\n /** Return a 403 if no tenant can be resolved (default: true) */\n required?: boolean\n\n /** Routes to skip tenant resolution (e.g., health checks) */\n excludeRoutes?: string[]\n\n /**\n * Per-tenant database switching configuration.\n * When set, a TENANT_DB DI token is registered that resolves to\n * the current tenant's database connection.\n */\n database?: import('./database').TenantDatabase\n}\n","import { AsyncLocalStorage } from 'node:async_hooks'\nimport type { TenantInfo } from './types'\n\n/**\n * AsyncLocalStorage instance that holds the current request's tenant.\n * Used internally by TenantAdapter to make tenant resolution request-scoped.\n */\nexport const tenantStorage = new AsyncLocalStorage<TenantInfo>()\n\n/**\n * Get the current request's tenant from AsyncLocalStorage.\n *\n * Returns `undefined` when called outside a request scope (e.g.,\n * during startup, in a background job, or in tests without setup).\n *\n * @example\n * ```ts\n * import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'\n *\n * function logForTenant(message: string) {\n * const tenant = getCurrentTenant()\n * console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)\n * }\n * ```\n */\nexport function getCurrentTenant(): TenantInfo | undefined {\n return tenantStorage.getStore()\n}\n","import { Logger, Scope, defineAdapter, type AdapterMiddleware } from '@forinda/kickjs'\nimport { PROTOCOL_VERSION, type IntrospectionSnapshot } from '@forinda/kickjs-devtools-kit'\nimport type { Request, Response, NextFunction } from 'express'\nimport { TENANT_CONTEXT, type TenantInfo, type MultiTenantOptions } from './types'\nimport { tenantStorage, getCurrentTenant } from './tenant.context'\n\nconst log = Logger.for('MultiTenant')\n\n/**\n * Multi-tenancy adapter for KickJS.\n *\n * Resolves the tenant from each request and makes it available via DI\n * (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.\n *\n * @example\n * ```ts\n * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * TenantAdapter({\n * strategy: 'header',\n * onTenantResolved: async (tenant) => {\n * // Load tenant config from DB, validate, etc.\n * },\n * }),\n * ],\n * })\n *\n * // In a service:\n * @Service()\n * class UserService {\n * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}\n * }\n * ```\n *\n * Multiple shards or independent tenant pipelines? Use `.scoped()` for\n * a per-shard instance — each one gets its own `name` (e.g.\n * `TenantAdapter:eu`) so `dependsOn` lookups stay unambiguous:\n *\n * ```ts\n * adapters: [\n * TenantAdapter.scoped('eu', { strategy: 'header', headerName: 'x-eu-tenant' }),\n * TenantAdapter.scoped('us', { strategy: 'header', headerName: 'x-us-tenant' }),\n * ]\n * ```\n */\nexport const TenantAdapter = defineAdapter<MultiTenantOptions>({\n name: 'TenantAdapter',\n defaults: {\n strategy: 'header',\n required: true,\n headerName: 'x-tenant-id',\n queryParam: 'tenantId',\n },\n build: (options) => {\n // Tracked across the request middleware so introspect() can report\n // a coarse \"tenants resolved since boot\" counter to DevTools without\n // any per-request overhead beyond the increment itself.\n let tenantsResolved = 0\n let tenantsRejected = 0\n\n return {\n // ── DevTools introspection (architecture.md §23) ───────────────\n introspect(): IntrospectionSnapshot {\n return {\n protocolVersion: PROTOCOL_VERSION,\n name: 'TenantAdapter',\n kind: 'adapter',\n state: {\n strategy:\n typeof options.strategy === 'function' ? 'custom' : (options.strategy ?? null),\n required: options.required ?? true,\n headerName: options.headerName ?? null,\n queryParam: options.queryParam ?? null,\n },\n tokens: { provides: ['kick/tenant/Context'], requires: [] },\n metrics: {\n tenantsResolved,\n tenantsRejected,\n },\n }\n },\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n if (options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await resolveTenant(req, options)\n\n if (!tenant) {\n if (options.required) {\n tenantsRejected++\n res\n .status(403)\n .json({ message: 'Tenant not found. Provide a valid tenant identifier.' })\n return\n }\n return next()\n }\n\n tenantsResolved++\n ;(req as unknown as { tenant: TenantInfo }).tenant = tenant\n\n if (options.onTenantResolved) {\n await options.onTenantResolved(tenant, req)\n }\n\n // Wrap the rest of the request in AsyncLocalStorage so\n // @Inject(TENANT_CONTEXT) and getCurrentTenant() return the\n // correct tenant for this request.\n tenantStorage.run(tenant, () => next())\n },\n phase: 'beforeGlobal',\n },\n ]\n },\n\n beforeStart({ container }) {\n // TRANSIENT so each resolution reads from AsyncLocalStorage.\n container.registerFactory(\n TENANT_CONTEXT,\n () => {\n const tenant = getCurrentTenant()\n if (!tenant) {\n throw new Error(\n 'TENANT_CONTEXT resolved outside request scope. ' +\n 'Ensure TenantAdapter middleware is active and the code runs within a request.',\n )\n }\n return tenant\n },\n Scope.TRANSIENT,\n )\n log.info(\n `Tenant resolution: ${typeof options.strategy === 'function' ? 'custom' : options.strategy}`,\n )\n },\n }\n },\n})\n\nasync function resolveTenant(\n req: Request,\n options: MultiTenantOptions,\n): Promise<TenantInfo | null> {\n const strategy = options.strategy\n\n if (typeof strategy === 'function') {\n return strategy(req)\n }\n\n switch (strategy) {\n case 'header': {\n const tenantId = req.get(options.headerName!)\n return tenantId ? { id: tenantId } : null\n }\n case 'subdomain': {\n const host = req.hostname\n const parts = host.split('.')\n if (parts.length >= 3) {\n return { id: parts[0] }\n }\n return null\n }\n case 'path': {\n const segments = req.path.split('/').filter(Boolean)\n if (segments.length > 0) {\n return { id: segments[0] }\n }\n return null\n }\n case 'query': {\n const tenantId = req.query[options.queryParam!] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\n }\n}\n","/**\n * Per-tenant database switching configuration.\n *\n * Three isolation modes, from strongest to weakest:\n * - `database` — each tenant has its own database\n * - `schema` — shared database, separate schemas (PostgreSQL)\n * - `discriminator` — shared tables with a tenant_id column\n */\n\nexport interface DatabaseConnectionInfo {\n host: string\n port?: number\n database: string\n user: string\n password: string\n}\n\nexport interface DatabasePerTenantConfig {\n mode: 'database'\n /** Resolve connection info for a tenant */\n resolve: (tenantId: string) => DatabaseConnectionInfo | Promise<DatabaseConnectionInfo>\n /** Connection pool settings */\n pool?: { min?: number; max?: number; idleTimeout?: number }\n /** Cache resolved connections (default TTL: 300_000ms = 5 min) */\n cache?: { ttl?: number }\n}\n\nexport interface SchemaPerTenantConfig {\n mode: 'schema'\n /** Base connection URL (shared database) */\n connection: string\n /** Schema name template. `${tenantId}` is replaced at runtime. Default: `'tenant_${tenantId}'` */\n schemaTemplate?: string\n}\n\nexport interface DiscriminatorConfig {\n mode: 'discriminator'\n /** Base connection URL (shared everything) */\n connection: string\n /** Column name used to scope queries. Default: `'tenant_id'` */\n column?: string\n}\n\nexport type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig\n\n/** DI token for the current tenant's database connection */\nexport const TENANT_DB = Symbol('TenantDB')\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAmBA,MAAa,iBAAiB,YAAwB,sBAAsB;;;;;;;ACZ5E,MAAa,gBAAgB,IAAI,mBAA+B;;;;;;;;;;;;;;;;;AAkBhE,SAAgB,mBAA2C;AACzD,QAAO,cAAc,UAAU;;;;ACpBjC,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CrC,MAAa,gBAAgB,cAAkC;CAC7D,MAAM;CACN,UAAU;EACR,UAAU;EACV,UAAU;EACV,YAAY;EACZ,YAAY;EACb;CACD,QAAQ,YAAY;EAIlB,IAAI,kBAAkB;EACtB,IAAI,kBAAkB;AAEtB,SAAO;GAEL,aAAoC;AAClC,WAAO;KACL,iBAAiB;KACjB,MAAM;KACN,MAAM;KACN,OAAO;MACL,UACE,OAAO,QAAQ,aAAa,aAAa,WAAY,QAAQ,YAAY;MAC3E,UAAU,QAAQ,YAAY;MAC9B,YAAY,QAAQ,cAAc;MAClC,YAAY,QAAQ,cAAc;MACnC;KACD,QAAQ;MAAE,UAAU,CAAC,sBAAsB;MAAE,UAAU,EAAE;MAAE;KAC3D,SAAS;MACP;MACA;MACD;KACF;;GAGH,aAAkC;AAChC,WAAO,CACL;KACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAClE,UAAI,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CAC5D,QAAO,MAAM;MAGf,MAAM,SAAS,MAAM,cAAc,KAAK,QAAQ;AAEhD,UAAI,CAAC,QAAQ;AACX,WAAI,QAAQ,UAAU;AACpB;AACA,YACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,cAAO,MAAM;;AAGf;AACE,UAA0C,SAAS;AAErD,UAAI,QAAQ,iBACV,OAAM,QAAQ,iBAAiB,QAAQ,IAAI;AAM7C,oBAAc,IAAI,cAAc,MAAM,CAAC;;KAEzC,OAAO;KACR,CACF;;GAGH,YAAY,EAAE,aAAa;AAEzB,cAAU,gBACR,sBACM;KACJ,MAAM,SAAS,kBAAkB;AACjC,SAAI,CAAC,OACH,OAAM,IAAI,MACR,+HAED;AAEH,YAAO;OAET,MAAM,UACP;AACD,QAAI,KACF,sBAAsB,OAAO,QAAQ,aAAa,aAAa,WAAW,QAAQ,WACnF;;GAEJ;;CAEJ,CAAC;AAEF,eAAe,cACb,KACA,SAC4B;CAC5B,MAAM,WAAW,QAAQ;AAEzB,KAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,SAAQ,UAAR;EACE,KAAK,UAAU;GACb,MAAM,WAAW,IAAI,IAAI,QAAQ,WAAY;AAC7C,UAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;EAEvC,KAAK,aAAa;GAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,OAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,UAAO;;EAET,KAAK,QAAQ;GACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,OAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,UAAO;;EAET,KAAK,SAAS;GACZ,MAAM,WAAW,IAAI,MAAM,QAAQ;AACnC,UAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;EAEvC,QACE,QAAO;;;;;;ACxIb,MAAa,YAAY,OAAO,WAAW"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forinda/kickjs-multi-tenant",
3
- "version": "3.1.3",
3
+ "version": "4.0.0",
4
4
  "description": "Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing",
5
5
  "keywords": [
6
6
  "kickjs",
@@ -21,13 +21,10 @@
21
21
  "@forinda/kickjs",
22
22
  "@forinda/kickjs-auth",
23
23
  "@forinda/kickjs-cli",
24
- "@forinda/kickjs-config",
25
- "@forinda/kickjs-core",
26
24
  "@forinda/kickjs-cron",
27
25
  "@forinda/kickjs-devtools",
28
26
  "@forinda/kickjs-drizzle",
29
27
  "@forinda/kickjs-graphql",
30
- "@forinda/kickjs-http",
31
28
  "@forinda/kickjs-mailer",
32
29
  "@forinda/kickjs-multi-tenant",
33
30
  "@forinda/kickjs-notifications",
@@ -63,18 +60,21 @@
63
60
  "output": [
64
61
  "dist/**"
65
62
  ],
66
- "dependencies": [
67
- "../core:build"
68
- ]
63
+ "dependencies": []
69
64
  }
70
65
  },
71
66
  "dependencies": {
72
- "reflect-metadata": "^0.2.2"
67
+ "reflect-metadata": "^0.2.2",
68
+ "@forinda/kickjs-devtools-kit": "4.0.0"
73
69
  },
74
70
  "devDependencies": {
75
- "@types/node": "^25.0.0",
76
- "typescript": "^5.9.2",
77
- "@forinda/kickjs": "3.1.3"
71
+ "@types/express": "^5.0.6",
72
+ "@types/node": "^25.6.0",
73
+ "express": "^5.1.0",
74
+ "typescript": "^6.0.3",
75
+ "unplugin-swc": "^1.5.7",
76
+ "vitest": "^4.1.5",
77
+ "@forinda/kickjs": "4.0.0"
78
78
  },
79
79
  "publishConfig": {
80
80
  "access": "public"
@@ -100,6 +100,7 @@
100
100
  "scripts": {
101
101
  "build": "wireit",
102
102
  "dev": "tsdown --watch",
103
+ "test": "vitest run",
103
104
  "typecheck": "tsc --noEmit",
104
105
  "clean": "rm -rf dist .wireit"
105
106
  }