@taicode/common-server 3.7.13 → 4.0.0-beta.1
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/output/app/auth.test.d.ts +2 -0
- package/output/app/auth.test.d.ts.map +1 -0
- package/output/app/auth.test.js +148 -0
- package/output/app/boot.d.ts +48 -0
- package/output/app/boot.d.ts.map +1 -0
- package/output/app/boot.js +123 -0
- package/output/app/config.test.d.ts +2 -0
- package/output/app/config.test.d.ts.map +1 -0
- package/output/app/config.test.js +132 -0
- package/output/app/context.d.ts +68 -0
- package/output/app/context.d.ts.map +1 -0
- package/output/app/context.js +73 -0
- package/output/app/core/cookie.d.ts +25 -0
- package/output/app/core/cookie.d.ts.map +1 -0
- package/output/app/core/cookie.js +22 -0
- package/output/app/core/identity.d.ts +12 -0
- package/output/app/core/identity.d.ts.map +1 -0
- package/output/app/core/identity.js +14 -0
- package/output/app/core/index.d.ts +44 -0
- package/output/app/core/index.d.ts.map +1 -0
- package/output/app/core/index.js +57 -0
- package/output/app/core/logging.d.ts +13 -0
- package/output/app/core/logging.d.ts.map +1 -0
- package/output/app/core/logging.js +29 -0
- package/output/app/core/openapi.d.ts +17 -0
- package/output/app/core/openapi.d.ts.map +1 -0
- package/output/app/core/openapi.js +42 -0
- package/output/app/core/request-id.d.ts +36 -0
- package/output/app/core/request-id.d.ts.map +1 -0
- package/output/app/core/request-id.js +66 -0
- package/output/app/core/response.d.ts +41 -0
- package/output/app/core/response.d.ts.map +1 -0
- package/output/app/core/response.js +89 -0
- package/output/app/define-server.negative-cases.d.ts +32 -0
- package/output/app/define-server.negative-cases.d.ts.map +1 -0
- package/output/app/define-server.negative-cases.js +209 -0
- package/output/app/define-server.test.d.ts +2 -0
- package/output/app/define-server.test.d.ts.map +1 -0
- package/output/app/define-server.test.js +97 -0
- package/output/app/entries.d.ts +29 -0
- package/output/app/entries.d.ts.map +1 -0
- package/output/app/entries.js +64 -0
- package/output/app/env.d.ts +59 -0
- package/output/app/env.d.ts.map +1 -0
- package/output/app/env.js +66 -0
- package/output/app/env.test.d.ts +2 -0
- package/output/app/env.test.d.ts.map +1 -0
- package/output/app/env.test.js +124 -0
- package/output/app/error-contract.test.d.ts +2 -0
- package/output/app/error-contract.test.d.ts.map +1 -0
- package/output/app/error-contract.test.js +125 -0
- package/output/app/errors.d.ts +20 -0
- package/output/app/errors.d.ts.map +1 -0
- package/output/app/errors.js +51 -0
- package/output/app/generate-openapi.test.d.ts +2 -0
- package/output/app/generate-openapi.test.d.ts.map +1 -0
- package/output/app/generate-openapi.test.js +123 -0
- package/output/app/index.d.ts +16 -0
- package/output/app/index.d.ts.map +1 -0
- package/output/app/index.js +18 -0
- package/output/app/lifecycle-logging.test.d.ts +2 -0
- package/output/app/lifecycle-logging.test.d.ts.map +1 -0
- package/output/app/lifecycle-logging.test.js +155 -0
- package/output/app/lifecycle.d.ts +70 -0
- package/output/app/lifecycle.d.ts.map +1 -0
- package/output/app/lifecycle.js +99 -0
- package/output/app/lifecycle.test.d.ts +2 -0
- package/output/app/lifecycle.test.d.ts.map +1 -0
- package/output/app/lifecycle.test.js +152 -0
- package/output/app/middleware/auth.d.ts +71 -0
- package/output/app/middleware/auth.d.ts.map +1 -0
- package/output/app/middleware/auth.js +190 -0
- package/output/app/middleware/context.d.ts +13 -0
- package/output/app/middleware/context.d.ts.map +1 -0
- package/output/app/middleware/context.js +15 -0
- package/output/app/middleware/cors.d.ts +14 -0
- package/output/app/middleware/cors.d.ts.map +1 -0
- package/output/app/middleware/cors.js +30 -0
- package/output/app/middleware/index.d.ts +17 -0
- package/output/app/middleware/index.d.ts.map +1 -0
- package/output/app/middleware/index.js +16 -0
- package/output/app/middleware/rate-limit.d.ts +24 -0
- package/output/app/middleware/rate-limit.d.ts.map +1 -0
- package/output/app/middleware/rate-limit.js +46 -0
- package/output/app/middleware-order.test.d.ts +2 -0
- package/output/app/middleware-order.test.d.ts.map +1 -0
- package/output/app/middleware-order.test.js +148 -0
- package/output/app/permission-operations.d.ts +48 -0
- package/output/app/permission-operations.d.ts.map +1 -0
- package/output/app/permission-operations.js +77 -0
- package/output/app/permission-operations.test.d.ts +2 -0
- package/output/app/permission-operations.test.d.ts.map +1 -0
- package/output/app/permission-operations.test.js +72 -0
- package/output/app/permission-registration.test.d.ts +2 -0
- package/output/app/permission-registration.test.d.ts.map +1 -0
- package/output/app/permission-registration.test.js +138 -0
- package/output/app/redis.d.ts +45 -0
- package/output/app/redis.d.ts.map +1 -0
- package/output/app/redis.js +61 -0
- package/output/app/registered-operations.test.d.ts +2 -0
- package/output/app/registered-operations.test.d.ts.map +1 -0
- package/output/app/registered-operations.test.js +57 -0
- package/output/app/request-id.test.d.ts +2 -0
- package/output/app/request-id.test.d.ts.map +1 -0
- package/output/app/request-id.test.js +130 -0
- package/output/app/resources.test.d.ts +2 -0
- package/output/app/resources.test.d.ts.map +1 -0
- package/output/app/resources.test.js +197 -0
- package/output/app/shutdown.d.ts +22 -0
- package/output/app/shutdown.d.ts.map +1 -0
- package/output/app/shutdown.js +30 -0
- package/output/app/shutdown.test.d.ts +2 -0
- package/output/app/shutdown.test.d.ts.map +1 -0
- package/output/app/shutdown.test.js +103 -0
- package/output/app/signal.d.ts +12 -0
- package/output/app/signal.d.ts.map +1 -0
- package/output/app/signal.js +45 -0
- package/output/app/smoke.test.d.ts +2 -0
- package/output/app/smoke.test.d.ts.map +1 -0
- package/output/app/smoke.test.js +210 -0
- package/output/app/test-support.d.ts +21 -0
- package/output/app/test-support.d.ts.map +1 -0
- package/output/app/test-support.js +24 -0
- package/output/app/type-augment.d.ts +34 -0
- package/output/app/type-augment.d.ts.map +1 -0
- package/output/app/type-augment.js +7 -0
- package/output/app/types.d.ts +211 -0
- package/output/app/types.d.ts.map +1 -0
- package/output/app/types.js +2 -0
- package/package.json +12 -5
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createRedisClient, RedisCache } from '../redis-cache/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* 引导期 Redis / cache helper(N2 §2.13)。
|
|
4
|
+
*
|
|
5
|
+
* 在 `resources(ctx)` 槽位里按需调用(框架自己**不**创建 Redis):
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* resources: (ctx) => {
|
|
9
|
+
* const redis = createRedis(ctx.env, ctx.log) // 无 REDIS_URL → undefined
|
|
10
|
+
* return { redis, cache: createCache({ env: ctx.env, prefix: 'account', redis }) }
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* `REDIS_URL` 由 helper 直接读 `env`(固定约定),**不**取自 `config`;`redis` / `cache` 一律是
|
|
15
|
+
* **惰性包装**:构造时不建连、不 ping,首次真正使用才建连 —— 「出 spec 不连库/Redis」结构性成立。
|
|
16
|
+
*/
|
|
17
|
+
/** 一级/二级缓存默认 TTL(秒),与 `RedisCache` 默认一致 */
|
|
18
|
+
export const DEFAULT_REDIS_CACHE_TTL_SECONDS = 300;
|
|
19
|
+
/**
|
|
20
|
+
* 构造 Redis 句柄(惰性包装:不建连、不 ping)。
|
|
21
|
+
*
|
|
22
|
+
* 无 `REDIS_URL` 时返回 `undefined` —— 资源包里写成 `redis: RedisHandle | undefined` 即可
|
|
23
|
+
* (显式可选,运行时缺省)。
|
|
24
|
+
*/
|
|
25
|
+
export function createRedis(env, log) {
|
|
26
|
+
const redisUrl = env.REDIS_URL;
|
|
27
|
+
if (!redisUrl) {
|
|
28
|
+
log?.debug({}, '[redis] REDIS_URL not configured; cache falls back to in-process L1');
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const client = createRedisClient({ url: redisUrl });
|
|
32
|
+
return {
|
|
33
|
+
client,
|
|
34
|
+
dispose: async () => {
|
|
35
|
+
if (client.isOpen) {
|
|
36
|
+
await client.quit().catch(() => undefined);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
client.destroy();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 构造缓存(前缀由调用方给出)。
|
|
46
|
+
*
|
|
47
|
+
* - 传 `redis` → 复用同一个客户端;
|
|
48
|
+
* - 未传 `redis` 但有 `REDIS_URL` → 自建客户端(由 `RedisCache` 自己持有并断开);
|
|
49
|
+
* - 都没有 → 纯进程内一级缓存(不建连)。
|
|
50
|
+
*/
|
|
51
|
+
export function createCache(params) {
|
|
52
|
+
if (params.redis) {
|
|
53
|
+
return new RedisCache({ prefix: params.prefix, redisClient: params.redis.client });
|
|
54
|
+
}
|
|
55
|
+
const redisUrl = params.env.REDIS_URL;
|
|
56
|
+
if (redisUrl) {
|
|
57
|
+
return new RedisCache({ prefix: params.prefix, redisUrl });
|
|
58
|
+
}
|
|
59
|
+
params.log?.debug({}, '[redis] REDIS_URL not configured; cache falls back to in-process L1');
|
|
60
|
+
return new RedisCache({ prefix: params.prefix });
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registered-operations.test.d.ts","sourceRoot":"","sources":["../../source/app/registered-operations.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineServer, generateOpenApi, startServer } from './entries.js';
|
|
3
|
+
import { createContextMiddleware } from './middleware/context.js';
|
|
4
|
+
import { readRegisteredOperations, toPermissionOperations } from './permission-operations.js';
|
|
5
|
+
/**
|
|
6
|
+
* 路由注册表 + swagger 时序。
|
|
7
|
+
*
|
|
8
|
+
* 关键点:自定义 `middlewares`(**不含 cors**)时,swagger 插件体也必须在任何路由注册之前执行完
|
|
9
|
+
* (`registerOpenApi` 内 `await app.after()`),路由注册表同样非空——两者都不依赖「恰好有 cors
|
|
10
|
+
* 顺带冲刷队列」这种隐式巧合。
|
|
11
|
+
*/
|
|
12
|
+
const listWidgetSchema = {
|
|
13
|
+
operationId: 'listWidget',
|
|
14
|
+
summary: 'List widgets',
|
|
15
|
+
tags: ['Widget'],
|
|
16
|
+
};
|
|
17
|
+
/** 未声明 `operationId` 的路由(回落 url,且不进权限目录) */
|
|
18
|
+
const plainSchema = {};
|
|
19
|
+
function createServer() {
|
|
20
|
+
return defineServer({
|
|
21
|
+
app: { service: 'registry', title: 'Registry', version: '1.0.0' },
|
|
22
|
+
resources: () => ({}),
|
|
23
|
+
config: () => ({}),
|
|
24
|
+
services: () => ({}),
|
|
25
|
+
// 自定义中间件:只有 context,**没有 cors**(不得靠 cors 冲刷 swagger 队列)
|
|
26
|
+
middlewares: () => [createContextMiddleware()],
|
|
27
|
+
routers: (ctx) => {
|
|
28
|
+
ctx.app.get('/widget/list', { schema: listWidgetSchema }, () => ({ items: [] }));
|
|
29
|
+
ctx.app.post('/widget/plain', { schema: plainSchema }, () => ({ ok: true }));
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
describe('路由注册表', () => {
|
|
34
|
+
it('自定义 middlewares(无 cors)时,注册表非空且能拿到 operationId', async () => {
|
|
35
|
+
const app = await startServer(createServer(), { listen: false });
|
|
36
|
+
try {
|
|
37
|
+
const operations = readRegisteredOperations(app);
|
|
38
|
+
expect(operations.length).toBeGreaterThan(0);
|
|
39
|
+
const listWidget = operations.find((entry) => entry.url === '/widget/list');
|
|
40
|
+
expect(listWidget).toMatchObject({ method: 'GET', url: '/widget/list', operationId: 'listWidget' });
|
|
41
|
+
// Fastify 为 GET 自动生成的 HEAD 路由不进注册表(与 swagger 同口径)
|
|
42
|
+
expect(operations.some((entry) => entry.method === 'HEAD')).toBe(false);
|
|
43
|
+
// 未声明 operationId 的路由不进权限目录
|
|
44
|
+
expect(toPermissionOperations(operations)).toEqual([
|
|
45
|
+
{ operation: 'listWidget', summary: 'List widgets', description: '', tags: ['Widget'] },
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await app.close();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
it('自定义 middlewares(无 cors)时,swagger 文档仍包含已注册路由(确定性冲刷)', async () => {
|
|
53
|
+
const spec = (await generateOpenApi(createServer()));
|
|
54
|
+
const paths = spec.paths ?? {};
|
|
55
|
+
expect(Object.keys(paths)).toEqual(expect.arrayContaining(['/widget/list', '/widget/plain']));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-id.test.d.ts","sourceRoot":"","sources":["../../source/app/request-id.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createRequestIdOptions, normalizeInboundRequestId, REQUEST_ID_HEADER, REQUEST_ID_LOG_LABEL, } from './core/request-id.js';
|
|
3
|
+
import { defineServer, startServer } from './entries.js';
|
|
4
|
+
/**
|
|
5
|
+
* `requestId` 契约(设计 §5.1 / §8)——**框架内置、锁定**。
|
|
6
|
+
*
|
|
7
|
+
* 入站 `x-request-id` 存在且非空 → 复用,否则 `randomUUID`;响应 header 统一回吐
|
|
8
|
+
* `x-request-id`;**不进响应体**;日志字段键名统一为全拼 `requestId`。
|
|
9
|
+
*/
|
|
10
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
function createRequestIdServer() {
|
|
12
|
+
return defineServer({
|
|
13
|
+
app: { service: 'request-id-test', title: 'Request Id Test', version: '1.0.0' },
|
|
14
|
+
resources: () => ({}),
|
|
15
|
+
config: () => ({}),
|
|
16
|
+
services: () => ({}),
|
|
17
|
+
middlewares: () => [],
|
|
18
|
+
routers: (ctx) => {
|
|
19
|
+
ctx.app.get('/echo', () => ({ ok: true }));
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** 启动一个可读取请求日志 bindings 的 app(`x-request-id` 归一化断言用) */
|
|
24
|
+
async function startBindingApp() {
|
|
25
|
+
let bindings;
|
|
26
|
+
const server = defineServer({
|
|
27
|
+
app: { service: 'request-id-normalize', title: 'Request Id Normalize', version: '1.0.0' },
|
|
28
|
+
resources: () => ({}),
|
|
29
|
+
config: () => ({}),
|
|
30
|
+
services: () => ({}),
|
|
31
|
+
middlewares: () => [],
|
|
32
|
+
routers: (ctx) => {
|
|
33
|
+
ctx.app.addHook('onRequest', (request, _reply, done) => {
|
|
34
|
+
bindings = request.log.bindings();
|
|
35
|
+
done();
|
|
36
|
+
});
|
|
37
|
+
ctx.app.get('/echo', () => ({ ok: true }));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const app = await startServer(server, { listen: false });
|
|
41
|
+
return { app, getBindings: () => bindings };
|
|
42
|
+
}
|
|
43
|
+
describe('requestId(设计 §5.1 / §8)', () => {
|
|
44
|
+
it('固定 header 名与日志字段名', () => {
|
|
45
|
+
expect(REQUEST_ID_HEADER).toBe('x-request-id');
|
|
46
|
+
expect(REQUEST_ID_LOG_LABEL).toBe('requestId');
|
|
47
|
+
const options = createRequestIdOptions();
|
|
48
|
+
expect(options.requestIdHeader).toBe('x-request-id');
|
|
49
|
+
expect(options.requestIdLogLabel).toBe('requestId');
|
|
50
|
+
});
|
|
51
|
+
it('genReqId:入站值非空则复用,否则生成 UUID', () => {
|
|
52
|
+
const { genReqId } = createRequestIdOptions();
|
|
53
|
+
expect(genReqId({ headers: { 'x-request-id': 'abc' } })).toBe('abc');
|
|
54
|
+
expect(genReqId({ headers: { 'x-request-id': ['abc', 'def'] } })).toBe('abc');
|
|
55
|
+
expect(genReqId({ headers: {} })).toMatch(UUID_PATTERN);
|
|
56
|
+
expect(genReqId({ headers: { 'x-request-id': ' ' } })).toMatch(UUID_PATTERN);
|
|
57
|
+
});
|
|
58
|
+
it('normalizeInboundRequestId:trim 后为空视为缺失(undefined),非空返回 trim 值', () => {
|
|
59
|
+
expect(normalizeInboundRequestId(undefined)).toBeUndefined();
|
|
60
|
+
expect(normalizeInboundRequestId('')).toBeUndefined();
|
|
61
|
+
expect(normalizeInboundRequestId(' ')).toBeUndefined();
|
|
62
|
+
expect(normalizeInboundRequestId('trace-abc')).toBe('trace-abc');
|
|
63
|
+
expect(normalizeInboundRequestId(' trace-abc ')).toBe('trace-abc');
|
|
64
|
+
expect(normalizeInboundRequestId(['abc', 'def'])).toBe('abc');
|
|
65
|
+
});
|
|
66
|
+
it('空白入站 header → 视为缺失,重新生成 UUID(request.id / 日志字段 / 响应 header 一致)', async () => {
|
|
67
|
+
const { app, getBindings } = await startBindingApp();
|
|
68
|
+
const response = await app.inject({ method: 'GET', url: '/echo', headers: { [REQUEST_ID_HEADER]: ' ' } });
|
|
69
|
+
expect(response.statusCode).toBe(200);
|
|
70
|
+
const requestId = String(response.headers[REQUEST_ID_HEADER]);
|
|
71
|
+
expect(requestId).toMatch(UUID_PATTERN);
|
|
72
|
+
expect(getBindings()).toEqual({ [REQUEST_ID_LOG_LABEL]: requestId });
|
|
73
|
+
await app.close();
|
|
74
|
+
});
|
|
75
|
+
it('带空格的入站 header → trim 后复用(request.id / 日志字段 / 响应 header 一致)', async () => {
|
|
76
|
+
const { app, getBindings } = await startBindingApp();
|
|
77
|
+
const response = await app.inject({
|
|
78
|
+
method: 'GET',
|
|
79
|
+
url: '/echo',
|
|
80
|
+
headers: { [REQUEST_ID_HEADER]: ' trace-trim ' },
|
|
81
|
+
});
|
|
82
|
+
expect(response.statusCode).toBe(200);
|
|
83
|
+
expect(response.headers[REQUEST_ID_HEADER]).toBe('trace-trim');
|
|
84
|
+
expect(getBindings()).toEqual({ [REQUEST_ID_LOG_LABEL]: 'trace-trim' });
|
|
85
|
+
await app.close();
|
|
86
|
+
});
|
|
87
|
+
it('缺失入站 header → 生成 UUID(request.id / 日志字段 / 响应 header 一致)', async () => {
|
|
88
|
+
const { app, getBindings } = await startBindingApp();
|
|
89
|
+
const response = await app.inject({ method: 'GET', url: '/echo' });
|
|
90
|
+
expect(response.statusCode).toBe(200);
|
|
91
|
+
const requestId = String(response.headers[REQUEST_ID_HEADER]);
|
|
92
|
+
expect(requestId).toMatch(UUID_PATTERN);
|
|
93
|
+
expect(getBindings()).toEqual({ [REQUEST_ID_LOG_LABEL]: requestId });
|
|
94
|
+
await app.close();
|
|
95
|
+
});
|
|
96
|
+
it('复用入站 header 并回吐到响应 header;日志绑定字段为 requestId;不进响应体', async () => {
|
|
97
|
+
let bindings;
|
|
98
|
+
const server = defineServer({
|
|
99
|
+
app: { service: 'request-id-integration', title: 'Request Id Integration', version: '1.0.0' },
|
|
100
|
+
resources: () => ({}),
|
|
101
|
+
config: () => ({}),
|
|
102
|
+
services: () => ({}),
|
|
103
|
+
middlewares: () => [],
|
|
104
|
+
routers: (ctx) => {
|
|
105
|
+
ctx.app.addHook('onRequest', (request, _reply, done) => {
|
|
106
|
+
bindings = request.log.bindings();
|
|
107
|
+
done();
|
|
108
|
+
});
|
|
109
|
+
ctx.app.get('/echo', () => ({ ok: true }));
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const app = await startServer(server, { listen: false });
|
|
113
|
+
const reused = await app.inject({ method: 'GET', url: '/echo', headers: { 'x-request-id': 'trace-abc' } });
|
|
114
|
+
expect(reused.statusCode).toBe(200);
|
|
115
|
+
expect(reused.headers[REQUEST_ID_HEADER]).toBe('trace-abc');
|
|
116
|
+
expect(bindings).toEqual({ requestId: 'trace-abc' });
|
|
117
|
+
expect(reused.body).not.toContain('trace-abc');
|
|
118
|
+
const generated = await app.inject({ method: 'GET', url: '/echo' });
|
|
119
|
+
expect(String(generated.headers[REQUEST_ID_HEADER])).toMatch(UUID_PATTERN);
|
|
120
|
+
expect(bindings).toHaveProperty(REQUEST_ID_LOG_LABEL);
|
|
121
|
+
await app.close();
|
|
122
|
+
});
|
|
123
|
+
it('startServer 冒烟:定义可跑、路由可用', async () => {
|
|
124
|
+
const app = await startServer(createRequestIdServer(), { listen: false });
|
|
125
|
+
const response = await app.inject({ method: 'GET', url: '/echo' });
|
|
126
|
+
expect(response.statusCode).toBe(200);
|
|
127
|
+
expect(response.json()).toEqual({ status: 'SUCCESS', message: 'ok', data: { ok: true } });
|
|
128
|
+
await app.close();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resources.test.d.ts","sourceRoot":"","sources":["../../source/app/resources.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import { defineServer, generateOpenApi, startServer } from './entries.js';
|
|
3
|
+
import { createTestConfigEnv } from './test-support.js';
|
|
4
|
+
/** 资源包实现(显式标注返回类型:否则资源实现的回调参数会吃隐式 `any`) */
|
|
5
|
+
function createResources() {
|
|
6
|
+
return {
|
|
7
|
+
database: { query: (sql) => `rows:${sql}` },
|
|
8
|
+
cache: { read: (key) => `${key}:cached` },
|
|
9
|
+
telemetry: false,
|
|
10
|
+
queue: undefined,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// ── 类型自证:`false` 键在 `Live<TestResources>` 中被剔除 ─────────
|
|
14
|
+
const assertLiveKeysRemovesFalse = () => {
|
|
15
|
+
expectTypeOf().toEqualTypeOf();
|
|
16
|
+
};
|
|
17
|
+
void assertLiveKeysRemovesFalse;
|
|
18
|
+
describe('resources 槽位(N1 §2 / N2 §2.3)', () => {
|
|
19
|
+
it('① 入参只有 env + log,且资源嵌套在 ctx.resources 下(config / services 都能用)', async () => {
|
|
20
|
+
let captured;
|
|
21
|
+
const observed = {};
|
|
22
|
+
const server = defineServer({
|
|
23
|
+
app: { service: 'resources-basic', title: 'Resources Basic', version: '1.0.0' },
|
|
24
|
+
resources: (ctx) => {
|
|
25
|
+
captured = ctx;
|
|
26
|
+
return createResources();
|
|
27
|
+
},
|
|
28
|
+
config: (ctx) => {
|
|
29
|
+
// 类型自证:精确到资源包里的声明类型(不是 unknown / any)
|
|
30
|
+
expectTypeOf(ctx.resources.database.query).toEqualTypeOf();
|
|
31
|
+
observed.inConfig = ctx.resources.database.query('select 1');
|
|
32
|
+
return { domain: 'example.com' };
|
|
33
|
+
},
|
|
34
|
+
services: (ctx) => {
|
|
35
|
+
expectTypeOf(ctx.resources.cache.read).toEqualTypeOf();
|
|
36
|
+
observed.inServices = ctx.resources.cache.read('domain');
|
|
37
|
+
return {};
|
|
38
|
+
},
|
|
39
|
+
middlewares: () => [],
|
|
40
|
+
routers: (ctx) => {
|
|
41
|
+
ctx.app.get('/resource', () => ({ ok: true }));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const app = await startServer(server, { listen: false });
|
|
45
|
+
try {
|
|
46
|
+
expect(Object.keys(captured ?? {}).sort()).toEqual(['env', 'log']);
|
|
47
|
+
expect(captured?.env.ENV).toBeTypeOf('string');
|
|
48
|
+
expect(typeof captured?.log.info).toBe('function');
|
|
49
|
+
expect(observed.inConfig).toBe('rows:select 1');
|
|
50
|
+
expect(observed.inServices).toBe('domain:cached');
|
|
51
|
+
const response = await app.inject({ method: 'GET', url: '/resource' });
|
|
52
|
+
expect(response.statusCode).toBe(200);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await app.close();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
it('② `false` 关闭资源:装配正常,键在类型与运行时都不存在', async () => {
|
|
59
|
+
let ctxInServices;
|
|
60
|
+
const server = defineServer({
|
|
61
|
+
app: { service: 'resources-disabled', title: 'Resources Disabled', version: '1.0.0' },
|
|
62
|
+
resources: createResources,
|
|
63
|
+
config: () => ({}),
|
|
64
|
+
services: (ctx) => {
|
|
65
|
+
// 编译期:`telemetry` 被 `Live<>` 剔除 → 访问必须编译失败
|
|
66
|
+
// @ts-expect-error `telemetry: false` 已从资源视图中剔除
|
|
67
|
+
void ctx.resources.telemetry;
|
|
68
|
+
ctxInServices = ctx;
|
|
69
|
+
return {};
|
|
70
|
+
},
|
|
71
|
+
middlewares: () => [],
|
|
72
|
+
routers: (ctx) => {
|
|
73
|
+
ctx.app.get('/alive', () => ({ ok: true }));
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const app = await startServer(server, { listen: false });
|
|
77
|
+
try {
|
|
78
|
+
// 运行时也不存在该键(与类型一致)
|
|
79
|
+
const resources = ctxInServices.resources;
|
|
80
|
+
expect('telemetry' in resources).toBe(false);
|
|
81
|
+
const response = await app.inject({ method: 'GET', url: '/alive' });
|
|
82
|
+
expect(response.statusCode).toBe(200);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await app.close();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
it('③ 缺省可选资源:类型为 `T | undefined`,运行时为 undefined(键仍在)', async () => {
|
|
89
|
+
const observed = {};
|
|
90
|
+
const server = defineServer({
|
|
91
|
+
app: { service: 'resources-optional', title: 'Resources Optional', version: '1.0.0' },
|
|
92
|
+
resources: createResources,
|
|
93
|
+
config: (ctx) => {
|
|
94
|
+
expectTypeOf(ctx.resources.queue).toEqualTypeOf();
|
|
95
|
+
observed.inConfig = ctx.resources.queue;
|
|
96
|
+
return {};
|
|
97
|
+
},
|
|
98
|
+
services: (ctx) => {
|
|
99
|
+
observed.inServices = ctx.resources.queue;
|
|
100
|
+
observed.keys = Object.keys(ctx.resources);
|
|
101
|
+
return {};
|
|
102
|
+
},
|
|
103
|
+
middlewares: () => [],
|
|
104
|
+
routers: () => undefined,
|
|
105
|
+
});
|
|
106
|
+
const app = await startServer(server, { listen: false });
|
|
107
|
+
try {
|
|
108
|
+
expect(observed.inConfig).toBeUndefined();
|
|
109
|
+
expect(observed.inServices).toBeUndefined();
|
|
110
|
+
expect(observed.keys).toContain('queue');
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await app.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
it('④ resources 允许异步:返回 Promise<资源包> 时同样是精确类型', async () => {
|
|
117
|
+
let domain;
|
|
118
|
+
const server = defineServer({
|
|
119
|
+
app: { service: 'resources-async', title: 'Resources Async', version: '1.0.0' },
|
|
120
|
+
resources: async () => ({
|
|
121
|
+
database: { query: (sql) => `async:${sql}` },
|
|
122
|
+
}),
|
|
123
|
+
config: (ctx) => {
|
|
124
|
+
expectTypeOf(ctx.resources.database.query).toEqualTypeOf();
|
|
125
|
+
return { domain: ctx.resources.database.query('domain') };
|
|
126
|
+
},
|
|
127
|
+
services: (ctx) => {
|
|
128
|
+
domain = ctx.config.domain;
|
|
129
|
+
return {};
|
|
130
|
+
},
|
|
131
|
+
middlewares: () => [],
|
|
132
|
+
routers: (ctx) => {
|
|
133
|
+
ctx.app.get('/ping', () => ({ ok: true }));
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const app = await startServer(server, { listen: false });
|
|
137
|
+
try {
|
|
138
|
+
expect(domain).toBe('async:domain');
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await app.close();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it('⑤ 框架不管资源生命周期:资源上的 dispose 不被调用(释放要放 services)', async () => {
|
|
145
|
+
const calls = [];
|
|
146
|
+
const server = defineServer({
|
|
147
|
+
app: { service: 'resources-lifecycle', title: 'Resources Lifecycle', version: '1.0.0' },
|
|
148
|
+
resources: () => ({
|
|
149
|
+
database: {
|
|
150
|
+
dispose: () => {
|
|
151
|
+
calls.push('dispose:resource');
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
config: () => ({}),
|
|
156
|
+
services: () => ({
|
|
157
|
+
databaseClient: {
|
|
158
|
+
dispose: () => {
|
|
159
|
+
calls.push('dispose:service');
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
middlewares: () => [],
|
|
164
|
+
routers: () => undefined,
|
|
165
|
+
});
|
|
166
|
+
const app = await startServer(server, { listen: false });
|
|
167
|
+
await app.close();
|
|
168
|
+
expect(calls).toEqual(['dispose:service']);
|
|
169
|
+
});
|
|
170
|
+
it('⑥ 出 spec 路径同样跑 resources(只构造,不建连)', async () => {
|
|
171
|
+
let calls = 0;
|
|
172
|
+
const server = defineServer({
|
|
173
|
+
app: { service: 'resources-openapi', title: 'Resources OpenApi', version: '1.0.0' },
|
|
174
|
+
resources: () => {
|
|
175
|
+
calls += 1;
|
|
176
|
+
return {};
|
|
177
|
+
},
|
|
178
|
+
config: () => ({}),
|
|
179
|
+
services: () => ({}),
|
|
180
|
+
middlewares: () => [],
|
|
181
|
+
routers: () => undefined,
|
|
182
|
+
});
|
|
183
|
+
await generateOpenApi(server);
|
|
184
|
+
expect(calls).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
/** `ResourcesView` 自证:`Awaited<>` 归一(同步 / 异步两写法等价) */
|
|
188
|
+
const assertResourcesViewNormalizes = () => {
|
|
189
|
+
expectTypeOf().toEqualTypeOf();
|
|
190
|
+
expectTypeOf().toEqualTypeOf();
|
|
191
|
+
};
|
|
192
|
+
void assertResourcesViewNormalizes;
|
|
193
|
+
/** 测试用 env 自证:固定读取约定里的五项 */
|
|
194
|
+
const assertTestConfigEnv = () => {
|
|
195
|
+
expectTypeOf(createTestConfigEnv().ENV).toEqualTypeOf();
|
|
196
|
+
};
|
|
197
|
+
void assertTestConfigEnv;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 固定关闭步(N1 §5)——**顺序写死在数组里**。
|
|
3
|
+
*
|
|
4
|
+
* 新口径下框架不再持有 Redis / 数据库 / 外链客户端(这些都在 `resources` 里,由各仓自行构造,
|
|
5
|
+
* 需要释放时放 `services` 单元);配置失效通道已删除,故没有 `unsubscribeConfig` 步。
|
|
6
|
+
*
|
|
7
|
+
* 固定两步:`stopProducers`(`ctx.disposer` 注册的清理)→ `disposeServices`(services 逆序)。
|
|
8
|
+
*
|
|
9
|
+
* 每步失败记日志、不阻断后续,最后聚合上报。
|
|
10
|
+
*/
|
|
11
|
+
export declare const SHUTDOWN_ORDER: readonly ["stopProducers", "disposeServices"];
|
|
12
|
+
export type ShutdownStepName = (typeof SHUTDOWN_ORDER)[number];
|
|
13
|
+
export type ShutdownHandles = Record<ShutdownStepName, () => void | Promise<void>>;
|
|
14
|
+
export interface ShutdownLogger {
|
|
15
|
+
error: (obj: unknown, msg?: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 顺序执行关闭步骤;单步抛错记录后**继续**后续步骤,最后聚合上报
|
|
19
|
+
* (避免「一步报错导致后面资源没关」)。
|
|
20
|
+
*/
|
|
21
|
+
export declare function runShutdown(handles: ShutdownHandles, log: ShutdownLogger): Promise<void>;
|
|
22
|
+
//# sourceMappingURL=shutdown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shutdown.d.ts","sourceRoot":"","sources":["../../source/app/shutdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,+CAAgD,CAAA;AAE3E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAA;AAE9D,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,gBAAgB,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;AAElF,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5C;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAe9F"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 固定关闭步(N1 §5)——**顺序写死在数组里**。
|
|
3
|
+
*
|
|
4
|
+
* 新口径下框架不再持有 Redis / 数据库 / 外链客户端(这些都在 `resources` 里,由各仓自行构造,
|
|
5
|
+
* 需要释放时放 `services` 单元);配置失效通道已删除,故没有 `unsubscribeConfig` 步。
|
|
6
|
+
*
|
|
7
|
+
* 固定两步:`stopProducers`(`ctx.disposer` 注册的清理)→ `disposeServices`(services 逆序)。
|
|
8
|
+
*
|
|
9
|
+
* 每步失败记日志、不阻断后续,最后聚合上报。
|
|
10
|
+
*/
|
|
11
|
+
export const SHUTDOWN_ORDER = ['stopProducers', 'disposeServices'];
|
|
12
|
+
/**
|
|
13
|
+
* 顺序执行关闭步骤;单步抛错记录后**继续**后续步骤,最后聚合上报
|
|
14
|
+
* (避免「一步报错导致后面资源没关」)。
|
|
15
|
+
*/
|
|
16
|
+
export async function runShutdown(handles, log) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
for (const step of SHUTDOWN_ORDER) {
|
|
19
|
+
try {
|
|
20
|
+
await handles[step]();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
errors.push(error);
|
|
24
|
+
log.error({ error, step }, '[shutdown] step failed');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (errors.length > 0) {
|
|
28
|
+
log.error({ errors, count: errors.length }, '[shutdown] completed with errors');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shutdown.test.d.ts","sourceRoot":"","sources":["../../source/app/shutdown.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineServer, startServer } from './entries.js';
|
|
3
|
+
import { runShutdown, SHUTDOWN_ORDER } from './shutdown.js';
|
|
4
|
+
function createShutdownLog() {
|
|
5
|
+
const entries = [];
|
|
6
|
+
return { entries, log: { error: (obj, msg) => void entries.push({ obj, msg }) } };
|
|
7
|
+
}
|
|
8
|
+
function createRecordingHandles(record, throwOn) {
|
|
9
|
+
const step = (name) => () => {
|
|
10
|
+
record.push(name);
|
|
11
|
+
if (name === throwOn) {
|
|
12
|
+
throw new Error(`boom:${name}`);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
stopProducers: step('stopProducers'),
|
|
17
|
+
disposeServices: step('disposeServices'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe('SHUTDOWN_ORDER', () => {
|
|
21
|
+
it('固定两步且顺序不可变', () => {
|
|
22
|
+
expect(SHUTDOWN_ORDER).toEqual(['stopProducers', 'disposeServices']);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('runShutdown', () => {
|
|
26
|
+
it('按固定序执行全部步骤', async () => {
|
|
27
|
+
const record = [];
|
|
28
|
+
const { log } = createShutdownLog();
|
|
29
|
+
await runShutdown(createRecordingHandles(record), log);
|
|
30
|
+
expect(record).toEqual([...SHUTDOWN_ORDER]);
|
|
31
|
+
});
|
|
32
|
+
it('单步抛错不阻断后续,并聚合上报', async () => {
|
|
33
|
+
const record = [];
|
|
34
|
+
const { entries, log } = createShutdownLog();
|
|
35
|
+
await runShutdown(createRecordingHandles(record, 'stopProducers'), log);
|
|
36
|
+
expect(record).toEqual([...SHUTDOWN_ORDER]);
|
|
37
|
+
const aggregate = entries.find((entry) => entry.msg === '[shutdown] completed with errors');
|
|
38
|
+
expect(aggregate).toBeDefined();
|
|
39
|
+
const payload = aggregate?.obj;
|
|
40
|
+
expect(payload.count).toBe(1);
|
|
41
|
+
expect(payload.errors).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('关闭流程集成', () => {
|
|
45
|
+
it('默认两步按序执行:disposer → services 逆序', async () => {
|
|
46
|
+
const calls = [];
|
|
47
|
+
const server = defineServer({
|
|
48
|
+
app: { service: 'shutdown-integration', title: 'Shutdown Integration', version: '1.0.0' },
|
|
49
|
+
resources: () => ({ database: { dispose: () => calls.push('disposeResource') } }),
|
|
50
|
+
config: () => ({}),
|
|
51
|
+
services: (ctx) => {
|
|
52
|
+
ctx.disposer.addDisposer(() => {
|
|
53
|
+
calls.push('stopProducers');
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
a: {
|
|
57
|
+
dispose: () => {
|
|
58
|
+
calls.push('disposeServiceA');
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
b: {
|
|
62
|
+
dispose: () => {
|
|
63
|
+
calls.push('disposeServiceB');
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
middlewares: () => [],
|
|
69
|
+
routers: () => undefined,
|
|
70
|
+
});
|
|
71
|
+
const app = await startServer(server, { listen: false });
|
|
72
|
+
await app.close();
|
|
73
|
+
// 资源不在生命周期内(框架只管 services)
|
|
74
|
+
expect(calls).toEqual(['stopProducers', 'disposeServiceB', 'disposeServiceA']);
|
|
75
|
+
});
|
|
76
|
+
it('StartServerOptions.shutdown 整体覆盖默认关闭流程', async () => {
|
|
77
|
+
const calls = [];
|
|
78
|
+
const server = defineServer({
|
|
79
|
+
app: { service: 'shutdown-override', title: 'Shutdown Override', version: '1.0.0' },
|
|
80
|
+
resources: () => ({}),
|
|
81
|
+
config: () => ({}),
|
|
82
|
+
services: () => ({
|
|
83
|
+
a: {
|
|
84
|
+
dispose: () => {
|
|
85
|
+
calls.push('default-dispose');
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
middlewares: () => [],
|
|
90
|
+
routers: () => undefined,
|
|
91
|
+
});
|
|
92
|
+
const app = await startServer(server, {
|
|
93
|
+
listen: false,
|
|
94
|
+
shutdown: async (ctx) => {
|
|
95
|
+
expect(ctx.app).toBeDefined();
|
|
96
|
+
expect(ctx.services).toBeDefined();
|
|
97
|
+
calls.push('custom');
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await app.close();
|
|
101
|
+
expect(calls).toEqual(['custom']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LoggerLike } from './types.js';
|
|
2
|
+
import type { FastifyInstance } from 'fastify';
|
|
3
|
+
/**
|
|
4
|
+
* 内置信号处理(设计 §7.1 `activate` 尾部 / §7.2)。
|
|
5
|
+
*
|
|
6
|
+
* `SIGTERM` / `SIGINT` / `uncaughtException` / `unhandledRejection` ⇒ `app.close()`(触发 `onClose`
|
|
7
|
+
* 的关闭 6 步)⇒ `process.exit(0/1)`。**仅 `listen: true` 时装信号处理**。
|
|
8
|
+
*
|
|
9
|
+
* @returns detach 函数(移除本次注册的监听器)
|
|
10
|
+
*/
|
|
11
|
+
export declare function attachSignalHandlers(app: FastifyInstance, log: LoggerLike): () => void;
|
|
12
|
+
//# sourceMappingURL=signal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signal.d.ts","sourceRoot":"","sources":["../../source/app/signal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE9C;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,UAAU,GAAG,MAAM,IAAI,CA2CtF"}
|