@deepexi/datasense-connector 3.0.1 → 3.1.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.
Files changed (185) hide show
  1. package/README.md +158 -139
  2. package/package.json +19 -28
  3. package/scripts/build.ts +188 -0
  4. package/src/define.ts +267 -0
  5. package/src/descriptor.ts +116 -0
  6. package/src/errors.ts +105 -0
  7. package/src/index.ts +108 -0
  8. package/src/internal/location.ts +23 -0
  9. package/src/runtime/guest/bridge.ts +238 -0
  10. package/src/runtime/guest/cache.ts +43 -0
  11. package/src/runtime/guest/check.ts +17 -0
  12. package/src/runtime/guest/crypto.ts +166 -0
  13. package/src/runtime/guest/ctx.ts +157 -0
  14. package/src/runtime/guest/db.ts +59 -0
  15. package/src/runtime/guest/failure.ts +69 -0
  16. package/src/runtime/guest/http.ts +194 -0
  17. package/src/runtime/guest/index.ts +102 -0
  18. package/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
  19. package/src/runtime/guest/loader.ts +210 -0
  20. package/src/runtime/guest/log.ts +47 -0
  21. package/src/runtime/guest/map.ts +45 -0
  22. package/src/runtime/guest/operation.ts +193 -0
  23. package/src/runtime/guest/paginate.ts +169 -0
  24. package/src/runtime/guest/pull.ts +177 -0
  25. package/src/runtime/guest/push.ts +92 -0
  26. package/src/runtime/guest/signal.ts +14 -0
  27. package/src/runtime/guest/suggest.ts +51 -0
  28. package/src/runtime/guest/time.ts +137 -0
  29. package/src/runtime/guest-runtime.ts +75 -0
  30. package/src/runtime/index.ts +53 -0
  31. package/src/runtime/protocol.ts +557 -0
  32. package/src/runtime/session.ts +216 -0
  33. package/src/testing/child.ts +21 -0
  34. package/src/testing/compliance.ts +343 -0
  35. package/src/testing/engine.ts +445 -0
  36. package/src/testing/fixtures.ts +591 -0
  37. package/src/testing/gateway.ts +482 -0
  38. package/src/testing/import-guard.ts +221 -0
  39. package/src/testing/index.ts +43 -0
  40. package/src/testing/local-db.ts +226 -0
  41. package/src/testing/mock-external-system.ts +249 -0
  42. package/src/testing/pinned-http.ts +144 -0
  43. package/src/testing/recorder.ts +140 -0
  44. package/src/testing/redaction.ts +94 -0
  45. package/src/testing/replay.ts +437 -0
  46. package/src/testing/run-tests.ts +115 -0
  47. package/src/testing/schema.ts +70 -0
  48. package/src/testing/source-loader.ts +374 -0
  49. package/src/testing/types.ts +78 -0
  50. package/src/types.ts +329 -0
  51. package/src/validate.ts +267 -0
  52. package/test/build.test.ts +159 -0
  53. package/test/define.test-d.ts +182 -0
  54. package/test/fixtures/acceptance.ts +156 -0
  55. package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
  56. package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
  57. package/test/fixtures/connectors/http-pull/package.json +5 -0
  58. package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
  59. package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
  60. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
  61. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
  62. package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
  63. package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
  64. package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
  65. package/test/fixtures/connectors/pg-pull/package.json +5 -0
  66. package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
  67. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
  68. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
  69. package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
  70. package/test/fixtures/descriptor.ts +54 -0
  71. package/test/fixtures/kingdee-example.ts +82 -0
  72. package/test/fixtures/record-acceptance.ts +32 -0
  73. package/test/runtime/ctx.test.ts +434 -0
  74. package/test/runtime/helpers.ts +157 -0
  75. package/test/runtime/jobs.test.ts +444 -0
  76. package/test/runtime/operation.test.ts +175 -0
  77. package/test/runtime/session.test.ts +229 -0
  78. package/test/testing/acceptance-fixtures.test.ts +69 -0
  79. package/test/testing/compliance.test.ts +247 -0
  80. package/test/testing/engine.test.ts +283 -0
  81. package/test/testing/fixtures.test.ts +221 -0
  82. package/test/testing/gateway.test.ts +337 -0
  83. package/test/testing/import-guard.test.ts +45 -0
  84. package/test/testing/mock-external-system.test.ts +144 -0
  85. package/test/testing/operation-fixtures.test.ts +160 -0
  86. package/test/testing/recorder.test.ts +163 -0
  87. package/test/testing/redaction.test.ts +54 -0
  88. package/test/testing/replay.test.ts +207 -0
  89. package/test/testing/run-tests.test.ts +137 -0
  90. package/test/testing/source-loader.test.ts +220 -0
  91. package/test/validate-config.test.ts +104 -0
  92. package/tsconfig.build.json +23 -0
  93. package/tsconfig.json +19 -0
  94. package/cjs/index.cjs +0 -1015
  95. package/cjs/runtime.cjs +0 -752
  96. package/cjs/testing.cjs +0 -3591
  97. package/errors.d.ts +0 -17
  98. package/errors.js +0 -53
  99. package/guest.cjs +0 -2659
  100. package/index.d.ts +0 -13
  101. package/index.js +0 -7
  102. package/internal/location.d.ts +0 -6
  103. package/internal/location.js +0 -8
  104. package/presentation.d.ts +0 -38
  105. package/presentation.js +0 -268
  106. package/runtime/guest/bridge.d.ts +0 -53
  107. package/runtime/guest/bridge.js +0 -187
  108. package/runtime/guest/cache.d.ts +0 -3
  109. package/runtime/guest/cache.js +0 -42
  110. package/runtime/guest/crypto.d.ts +0 -13
  111. package/runtime/guest/crypto.js +0 -177
  112. package/runtime/guest/ctx.d.ts +0 -30
  113. package/runtime/guest/ctx.js +0 -124
  114. package/runtime/guest/db.d.ts +0 -6
  115. package/runtime/guest/db.js +0 -59
  116. package/runtime/guest/discover.d.ts +0 -29
  117. package/runtime/guest/discover.js +0 -51
  118. package/runtime/guest/failure.d.ts +0 -24
  119. package/runtime/guest/failure.js +0 -63
  120. package/runtime/guest/http.d.ts +0 -4
  121. package/runtime/guest/http.js +0 -204
  122. package/runtime/guest/index.d.ts +0 -12
  123. package/runtime/guest/index.js +0 -89
  124. package/runtime/guest/limits.js +0 -12
  125. package/runtime/guest/loader.d.ts +0 -15
  126. package/runtime/guest/loader.js +0 -42
  127. package/runtime/guest/log.d.ts +0 -10
  128. package/runtime/guest/log.js +0 -36
  129. package/runtime/guest/map.d.ts +0 -4
  130. package/runtime/guest/map.js +0 -45
  131. package/runtime/guest/paginate.d.ts +0 -6
  132. package/runtime/guest/paginate.js +0 -180
  133. package/runtime/guest/pull.d.ts +0 -12
  134. package/runtime/guest/pull.js +0 -150
  135. package/runtime/guest/push.d.ts +0 -18
  136. package/runtime/guest/push.js +0 -80
  137. package/runtime/guest/signal.d.ts +0 -3
  138. package/runtime/guest/signal.js +0 -12
  139. package/runtime/guest/time.d.ts +0 -5
  140. package/runtime/guest/time.js +0 -127
  141. package/runtime/guest-runtime.d.ts +0 -7
  142. package/runtime/guest-runtime.js +0 -51
  143. package/runtime/index.d.ts +0 -18
  144. package/runtime/index.js +0 -3
  145. package/runtime/protocol.d.ts +0 -276
  146. package/runtime/protocol.js +0 -409
  147. package/runtime/session.d.ts +0 -12
  148. package/runtime/session.js +0 -191
  149. package/t.d.ts +0 -18
  150. package/t.js +0 -26
  151. package/testing/child.cjs +0 -350
  152. package/testing/child.d.ts +0 -1
  153. package/testing/child.js +0 -12
  154. package/testing/compliance.d.ts +0 -41
  155. package/testing/compliance.js +0 -291
  156. package/testing/engine.d.ts +0 -66
  157. package/testing/engine.js +0 -339
  158. package/testing/fixtures.d.ts +0 -145
  159. package/testing/fixtures.js +0 -407
  160. package/testing/gateway.d.ts +0 -65
  161. package/testing/gateway.js +0 -402
  162. package/testing/import-guard.d.ts +0 -57
  163. package/testing/import-guard.js +0 -187
  164. package/testing/index.d.ts +0 -33
  165. package/testing/index.js +0 -10
  166. package/testing/local-db.d.ts +0 -27
  167. package/testing/local-db.js +0 -181
  168. package/testing/pinned-http.d.ts +0 -14
  169. package/testing/pinned-http.js +0 -125
  170. package/testing/recorder.d.ts +0 -15
  171. package/testing/recorder.js +0 -116
  172. package/testing/redaction.d.ts +0 -28
  173. package/testing/redaction.js +0 -67
  174. package/testing/replay.d.ts +0 -50
  175. package/testing/replay.js +0 -313
  176. package/testing/schema.d.ts +0 -13
  177. package/testing/schema.js +0 -65
  178. package/testing/source-loader.d.ts +0 -19
  179. package/testing/source-loader.js +0 -238
  180. package/testing/types.d.ts +0 -79
  181. package/testing/types.js +0 -18
  182. package/types.d.ts +0 -302
  183. package/types.js +0 -13
  184. package/validate.d.ts +0 -78
  185. package/validate.js +0 -597
@@ -0,0 +1,69 @@
1
+ /** 把连接器抛出的值归成失败回报。错误类别只按 `code` 字段判断(连接器可能抛任意值)。 */
2
+ import type { GuestFailureCode, GuestLog } from '../protocol';
3
+ import { isAborted } from './bridge';
4
+ import { CONNECTOR_FILENAME } from './limits';
5
+
6
+ export interface ErrorInfo {
7
+ code: string | null;
8
+ message: string;
9
+ stack: string | undefined;
10
+ }
11
+
12
+ /** 只取 code / message / stack 三个原始值;读属性抛错或类型不对时按普通异常处理。 */
13
+ export function errorInfo(error: unknown): ErrorInfo {
14
+ let code: string | null = null;
15
+ let message = 'connector threw a non-error value';
16
+ let stack: string | undefined;
17
+ if (error !== null && (typeof error === 'object' || typeof error === 'function')) {
18
+ const value = error as { code?: unknown; message?: unknown; stack?: unknown };
19
+ try { if (typeof value.code === 'string') code = value.code; } catch { code = null; }
20
+ try { if (typeof value.message === 'string') message = value.message; } catch { /* 保留缺省文案 */ }
21
+ try { if (typeof value.stack === 'string') stack = value.stack; } catch { stack = undefined; }
22
+ } else if (typeof error === 'string') {
23
+ message = error;
24
+ }
25
+ return { code, message, stack };
26
+ }
27
+
28
+ /**
29
+ * 调用栈只留第一行(错误文案)与连接器代码自己的帧:宿主帧带 runner 的本地路径,guest 运行时的帧对作者没有意义。
30
+ * 连接器代码以 `connector.js` 为文件名加载、包装函数与第一行同行,行号直接对应打包产物。
31
+ */
32
+ export function connectorStack(stack: string | undefined): string | undefined {
33
+ if (stack === undefined) return undefined;
34
+ const lines = stack.split('\n');
35
+ const frames = lines.slice(1).filter((line) => line.includes(`${CONNECTOR_FILENAME}:`));
36
+ if (frames.length === 0) return undefined;
37
+ return [lines[0] ?? '', ...frames].join('\n');
38
+ }
39
+
40
+ const CODE_MAP: Readonly<Record<string, GuestFailureCode>> = Object.freeze({
41
+ AUTH_ERROR: 'AUTH_ERROR',
42
+ TRANSIENT_ERROR: 'TRANSIENT_ERROR',
43
+ RATE_LIMITED: 'TRANSIENT_ERROR',
44
+ SCHEMA_ERROR: 'SCHEMA_ERROR',
45
+ FATAL_ERROR: 'FATAL_ERROR',
46
+ CANCELED: 'CANCELED',
47
+ });
48
+
49
+ export interface FailureMessage {
50
+ type: 'failure';
51
+ code: GuestFailureCode;
52
+ message: string;
53
+ stack?: string;
54
+ logs: GuestLog[];
55
+ }
56
+
57
+ export function failure(code: GuestFailureCode, message: string, logs: GuestLog[], stack?: string): FailureMessage {
58
+ return { type: 'failure', code, message, ...(stack !== undefined ? { stack } : {}), logs };
59
+ }
60
+
61
+ /** 连接器异常 → 失败回报:认得的 code 原样映射,其余(含宿主调用的错误码)按 FATAL_ERROR,文案带上原 code;运行已取消时一律 CANCELED。 */
62
+ export function failureFromError(error: unknown, logs: GuestLog[]): FailureMessage {
63
+ const info = errorInfo(error);
64
+ const stack = connectorStack(info.stack);
65
+ if (isAborted()) return failure('CANCELED', 'run was canceled', logs, stack);
66
+ const mapped = info.code !== null && Object.hasOwn(CODE_MAP, info.code) ? CODE_MAP[info.code] : undefined;
67
+ if (mapped) return failure(mapped, info.message, logs, stack);
68
+ return failure('FATAL_ERROR', info.code === null ? info.message : `${info.code}: ${info.message}`, logs, stack);
69
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `ctx.http`:经宿主调用 `http` 出站,网关只做一次尝试,重试在这里做:
3
+ * - 网关限流 `EGRESS_RATE_LIMITED`(带 `retryAfterMs`)与上游 429(按 `Retry-After`):等待后重发,最多 5 次,仍不行抛 `RateLimited`;
4
+ * - 上游 502 / 503 / 504 与网关超时 `EGRESS_TIMEOUT`:指数退避(200 ms 起,×2)重试 3 次,仍不行抛 `TransientError`。
5
+ * 请求可能已被上游处理,所以缺省只对幂等方法(RFC 9110 §9.2.2:GET / HEAD / PUT / DELETE)重试,
6
+ * POST / PATCH 直接抛 `TransientError`,除非请求传了 `retry: true`;`retry: false` 时一律不重试;
7
+ * - 上游 401 抛 `AuthError`,其他 4xx / 5xx 抛 `FatalError`;`raw: true` 时不按状态码处理。
8
+ */
9
+ import { errors } from '../../errors';
10
+ import type { HttpClient, HttpMethod, HttpOptions, HttpResponse } from '../../types';
11
+ import type { EgressHttpRequest, EgressHttpResponse } from '../protocol';
12
+ import { HostCallFailure, call, jsonParse, jsonStringify, now, sleep, throwIfAborted } from './bridge';
13
+ import { bytesToBase64 } from './crypto';
14
+ import { MAX_RETRY_WAIT_MS, RATE_LIMIT_BACKOFF_MS, RATE_LIMIT_RETRIES, TRANSIENT_BACKOFF_MS, TRANSIENT_RETRIES } from './limits';
15
+
16
+ const METHODS: readonly string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
17
+ const ABSOLUTE_URL = /^https?:\/\//i;
18
+ const TRANSIENT_STATUS: readonly number[] = [502, 503, 504];
19
+ /** RFC 9110 §9.2.2 的幂等方法(`METHODS` 里没有 OPTIONS / TRACE)。 */
20
+ const IDEMPOTENT_METHODS: readonly string[] = ['GET', 'HEAD', 'PUT', 'DELETE'];
21
+ const JSON_CONTENT_TYPE = /(?:^|[/+])json\s*(?:;|$)/i;
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ function stringify(value: unknown): string {
28
+ if (typeof value === 'string') return value;
29
+ if (value instanceof Date) return value.toISOString();
30
+ if (typeof value === 'object' && value !== null) return jsonStringify(value) as string;
31
+ return String(value);
32
+ }
33
+
34
+ /** 相对路径拼在 `auth.baseUrl` 上;没有时拼在缺省基址(连接器 `hosts` 里第一个确定的主机)上。 */
35
+ export function resolveUrl(url: string, baseUrl: string | null): string {
36
+ if (ABSOLUTE_URL.test(url)) return url;
37
+ if (!baseUrl) throw new TypeError(`relative URL ${url} needs auth.baseUrl or a fixed host in the connector's hosts`);
38
+ const base = baseUrl.replace(/\/+$/, '');
39
+ return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
40
+ }
41
+
42
+ function buildHeaders(options: HttpOptions): Record<string, string> {
43
+ const headers: Record<string, string> = {};
44
+ for (const source of [options.auth?.headers, options.headers]) {
45
+ if (source === undefined) continue;
46
+ if (!isRecord(source)) throw new TypeError('headers must be an object');
47
+ for (const [name, value] of Object.entries(source)) {
48
+ if (value === undefined || value === null) continue;
49
+ headers[name.toLowerCase()] = stringify(value);
50
+ }
51
+ }
52
+ return headers;
53
+ }
54
+
55
+ function buildQuery(query: HttpOptions['query']): Record<string, string | string[]> {
56
+ const out: Record<string, string | string[]> = {};
57
+ if (query === undefined) return out;
58
+ if (!isRecord(query)) throw new TypeError('query must be an object');
59
+ for (const [name, value] of Object.entries(query)) {
60
+ if (value === undefined || value === null) continue;
61
+ out[name] = Array.isArray(value) ? value.filter((item) => item !== undefined && item !== null).map(stringify) : stringify(value);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function buildBody(body: unknown, headers: Record<string, string>): Pick<EgressHttpRequest, 'body' | 'bodyEncoding'> {
67
+ if (body === undefined || body === null) return { body: null, bodyEncoding: 'utf8' };
68
+ if (typeof body === 'string') return { body, bodyEncoding: 'utf8' };
69
+ if (body instanceof Uint8Array) return { body: bytesToBase64(Array.from(body)), bodyEncoding: 'base64' };
70
+ if (body instanceof ArrayBuffer) return { body: bytesToBase64(Array.from(new Uint8Array(body))), bodyEncoding: 'base64' };
71
+ const text = jsonStringify(body);
72
+ if (typeof text !== 'string') throw new TypeError('request body is not serializable');
73
+ if (headers['content-type'] === undefined) headers['content-type'] = 'application/json';
74
+ return { body: text, bodyEncoding: 'utf8' };
75
+ }
76
+
77
+ function buildRequest(method: string, url: string, options: HttpOptions, defaultBaseUrl: string | null): EgressHttpRequest {
78
+ const upper = typeof method === 'string' ? method.toUpperCase() : '';
79
+ if (!METHODS.includes(upper)) throw new TypeError(`unsupported HTTP method ${String(method)}`);
80
+ if (typeof url !== 'string' || url.length === 0) throw new TypeError('url must be a non-empty string');
81
+ if (!isRecord(options as unknown)) throw new TypeError('options must be an object');
82
+ const baseUrl = options.auth?.baseUrl ?? defaultBaseUrl;
83
+ const headers = buildHeaders(options);
84
+ const request: EgressHttpRequest = {
85
+ method: upper as EgressHttpRequest['method'],
86
+ url: resolveUrl(url, typeof baseUrl === 'string' ? baseUrl : null),
87
+ headers,
88
+ query: buildQuery(options.query),
89
+ ...buildBody(options.body, headers),
90
+ };
91
+ if (options.retry !== undefined && typeof options.retry !== 'boolean') throw new TypeError('retry must be a boolean');
92
+ if (options.timeoutMs !== undefined) {
93
+ if (typeof options.timeoutMs !== 'number' || !(options.timeoutMs > 0)) throw new TypeError('timeoutMs must be a positive number');
94
+ request.timeoutMs = options.timeoutMs;
95
+ }
96
+ return request;
97
+ }
98
+
99
+ function toResponse(value: unknown, parseJson: boolean): HttpResponse {
100
+ if (!isRecord(value) || typeof value.status !== 'number' || typeof value.body !== 'string') throw new TypeError('http host call returned a malformed response');
101
+ const raw = value as unknown as EgressHttpResponse;
102
+ const headers: Record<string, string> = {};
103
+ if (isRecord(raw.headers)) {
104
+ for (const [name, item] of Object.entries(raw.headers)) if (typeof item === 'string') headers[name.toLowerCase()] = item;
105
+ }
106
+ // 二进制响应的 text 是 base64 原文;只有 utf8 文本才按 JSON 解析。
107
+ const text = raw.body;
108
+ const url = typeof raw.url === 'string' ? raw.url : '';
109
+ let data: unknown = text;
110
+ if (parseJson && raw.bodyEncoding !== 'base64' && JSON_CONTENT_TYPE.test(headers['content-type'] ?? '') && text.length > 0) {
111
+ try {
112
+ data = jsonParse(text);
113
+ } catch {
114
+ throw new errors.FatalError(`response from ${url} is not valid JSON`);
115
+ }
116
+ }
117
+ return { status: raw.status, url, headers, data, text };
118
+ }
119
+
120
+ /** `Retry-After`:秒数或 HTTP 日期;取不到返回 undefined。 */
121
+ function retryAfterMs(headers: Record<string, string>): number | undefined {
122
+ const value = headers['retry-after'];
123
+ if (value === undefined) return undefined;
124
+ const trimmed = value.trim();
125
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed) * 1000;
126
+ const at = Date.parse(trimmed);
127
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now());
128
+ }
129
+
130
+ function clampWait(ms: number): number {
131
+ return Math.min(Math.max(0, ms), MAX_RETRY_WAIT_MS);
132
+ }
133
+
134
+ export function createHttp(defaultBaseUrl: string | null): HttpClient {
135
+ async function request<T = unknown>(method: HttpMethod, url: string, options: HttpOptions = {}): Promise<HttpResponse<T>> {
136
+ const req = buildRequest(method, url, options, defaultBaseUrl);
137
+ const raw = options.raw === true;
138
+ const label = `${req.method} ${req.url}`;
139
+ const retryTransient = options.retry ?? IDEMPOTENT_METHODS.includes(req.method);
140
+ const transientLimit = retryTransient ? TRANSIENT_RETRIES : 0;
141
+ let rateRetries = 0;
142
+ let transientRetries = 0;
143
+ for (;;) {
144
+ throwIfAborted();
145
+ let value: unknown;
146
+ try {
147
+ value = await call('http', req);
148
+ } catch (error) {
149
+ if (error instanceof HostCallFailure && error.code === 'EGRESS_RATE_LIMITED') {
150
+ const wait = error.retryAfterMs ?? RATE_LIMIT_BACKOFF_MS * 2 ** rateRetries;
151
+ if (rateRetries >= RATE_LIMIT_RETRIES) throw new errors.RateLimited(wait, `${label} is still rate limited after ${RATE_LIMIT_RETRIES} retries`);
152
+ rateRetries += 1;
153
+ await sleep(clampWait(wait));
154
+ continue;
155
+ }
156
+ if (error instanceof HostCallFailure && error.code === 'EGRESS_TIMEOUT') {
157
+ if (transientRetries >= transientLimit) throw new errors.TransientError(`${label} timed out after ${transientRetries} retries`);
158
+ await sleep(TRANSIENT_BACKOFF_MS * 2 ** transientRetries);
159
+ transientRetries += 1;
160
+ continue;
161
+ }
162
+ throw error;
163
+ }
164
+ const response = toResponse(value, !raw);
165
+ if (raw) return response as HttpResponse<T>;
166
+ const status = response.status;
167
+ if (status === 429) {
168
+ const wait = retryAfterMs(response.headers) ?? RATE_LIMIT_BACKOFF_MS * 2 ** rateRetries;
169
+ if (rateRetries >= RATE_LIMIT_RETRIES) throw new errors.RateLimited(wait, `${label} returned 429 after ${RATE_LIMIT_RETRIES} retries`);
170
+ rateRetries += 1;
171
+ await sleep(clampWait(wait));
172
+ continue;
173
+ }
174
+ if (TRANSIENT_STATUS.includes(status)) {
175
+ if (transientRetries >= transientLimit) throw new errors.TransientError(`${label} returned ${status} after ${transientRetries} retries`);
176
+ await sleep(TRANSIENT_BACKOFF_MS * 2 ** transientRetries);
177
+ transientRetries += 1;
178
+ continue;
179
+ }
180
+ if (status === 401) throw new errors.AuthError(`${label} returned 401`);
181
+ if (status >= 400) throw new errors.FatalError(`${label} returned ${status}`);
182
+ return response as HttpResponse<T>;
183
+ }
184
+ }
185
+ const verb = (method: HttpMethod) => <T = unknown>(url: string, options?: HttpOptions) => request<T>(method, url, options);
186
+ return Object.freeze({
187
+ get: verb('GET'),
188
+ post: verb('POST'),
189
+ put: verb('PUT'),
190
+ patch: verb('PATCH'),
191
+ delete: verb('DELETE'),
192
+ request,
193
+ });
194
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * guest 运行时入口(在 vm 上下文**里**执行)。runner 用 `bundleTrustedEntry(GUEST_ENTRY_PATH)` 把本文件打成 CJS 字符串,
3
+ * 宿主会话在新上下文里先执行它、调用 `bootGuest()`,之后才编译并加载连接器代码。
4
+ *
5
+ * 交给宿主的只有 `start(json, factory)`、`handle(json)`、`poll()` 三个上下文自己的函数,参数是字符串
6
+ * 与上下文里编译出的连接器工厂函数;SDK 的公开 API(`defineStream` / `errors` …)由 guest 自己提供给连接器的
7
+ * `require`,不从宿主传对象。
8
+ *
9
+ * 作业开始前先加载产物、核对描述与实现(见 `loader.ts`);不一致时任何作业都以 `CONNECTOR_LOAD_FAILED` 失败。
10
+ */
11
+ import { validateConfig } from '../../validate';
12
+ import type { GuestJob } from '../protocol';
13
+ import { finish, handle, jsonParse, poll } from './bridge';
14
+ import { runCheck } from './check';
15
+ import { prepareSecrets } from './ctx';
16
+ import { connectorStack, errorInfo, failure, failureFromError, type FailureMessage } from './failure';
17
+ import { loadConnector, type LoadedConnector } from './loader';
18
+ import { createLogBuffer } from './log';
19
+ import { runOperation } from './operation';
20
+ import { runPull } from './pull';
21
+ import { runPush } from './push';
22
+ import { runSuggest } from './suggest';
23
+
24
+ export interface GuestApi {
25
+ /** 只生效一次:`json` 为 `{ expect, job }`,`factory` 为上下文里编译出的 `(function (module, exports, require) {…})`。 */
26
+ start(json: string, factory: unknown): void;
27
+ handle(json: string): void;
28
+ poll(): string;
29
+ }
30
+
31
+ type Outcome = { type: 'result'; result: unknown } | FailureMessage;
32
+
33
+ /**
34
+ * Bun 1.3 实测,新上下文里这三项要在连接器代码运行前拿掉:
35
+ * - WebAssembly:Bun 的 node:vm 不执行 `codeGeneration.wasm = false`,`new WebAssembly.Module` 仍可编译;
36
+ * - ShadowRealm:`evaluate(字符串)` 绕过 `codeGeneration.strings`,且会让 Bun 段错误;
37
+ * - console:直接写子进程 stdout,连接器只能经 `ctx.log` 记日志。
38
+ */
39
+ const STRIPPED_GLOBALS = ['WebAssembly', 'ShadowRealm', 'console'];
40
+
41
+ function issuesText(issues: ReadonlyArray<{ path: string; message: string }>): string {
42
+ return issues.map((issue) => `${issue.path || '(root)'}: ${issue.message}`).join('; ');
43
+ }
44
+
45
+ async function run(connector: LoadedConnector, job: GuestJob): Promise<Outcome> {
46
+ if (job.kind === 'push') return runPush(connector, job);
47
+ if (job.kind === 'operation') return runOperation(connector, job);
48
+ if (job.kind === 'pull' && !Object.hasOwn(connector.streams, job.stream)) {
49
+ return failure('CONNECTOR_LOAD_FAILED', `connector has no stream named ${job.stream}`, []);
50
+ }
51
+ const logs = createLogBuffer();
52
+ const issues = validateConfig(connector.descriptor.config, job.config);
53
+ if (issues.length > 0) return failure('CONFIG_INVALID', `invalid connection config: ${issuesText(issues)}`, logs.drain());
54
+ let secrets: Readonly<Record<string, string>>;
55
+ try {
56
+ secrets = await prepareSecrets(connector.descriptor, job.secretFields);
57
+ } catch (error) {
58
+ return failureFromError(error, logs.drain());
59
+ }
60
+ const options = { config: job.config, secrets, descriptor: connector.descriptor, logs, rateLimit: job.rateLimit };
61
+ if (job.kind === 'check') return runCheck(connector, options);
62
+ if (job.kind === 'suggest') return runSuggest(connector, job, options);
63
+ return runPull(connector, job, options);
64
+ }
65
+
66
+ async function execute(json: string, factory: unknown): Promise<Outcome> {
67
+ const { expect, job } = jsonParse(json) as { expect: { name: string; version: string }; job: GuestJob };
68
+ const loaded = loadConnector(factory, expect);
69
+ if (!loaded.ok) return failure(loaded.code, loaded.message, [], connectorStack(loaded.stack));
70
+ return run(loaded.connector, job);
71
+ }
72
+
73
+ let started = false;
74
+
75
+ function start(json: string, factory: unknown): void {
76
+ if (started || typeof json !== 'string') return;
77
+ started = true;
78
+ let pending: Promise<Outcome>;
79
+ try {
80
+ pending = execute(json, factory);
81
+ } catch (error) {
82
+ finish(failure('FATAL_ERROR', errorInfo(error).message, []) as unknown as Record<string, unknown>);
83
+ return;
84
+ }
85
+ pending.then(
86
+ (outcome) => finish(outcome as unknown as Record<string, unknown>),
87
+ (error: unknown) => finish(failure('FATAL_ERROR', `connector runtime failed: ${errorInfo(error).message}`, []) as unknown as Record<string, unknown>),
88
+ );
89
+ }
90
+
91
+ export function bootGuest(): GuestApi {
92
+ for (const name of STRIPPED_GLOBALS) Reflect.deleteProperty(globalThis, name);
93
+ return Object.freeze({ start, handle, poll });
94
+ }
95
+
96
+ /**
97
+ * 进程内执行(`./testing` 的 `createInProcessExecutor`,给 vitest 用):每次执行都重新求值一份 guest 运行时,
98
+ * 这里的全局对象是宿主进程自己的,所以不拿掉任何全局;其余与 `bootGuest` 相同。
99
+ */
100
+ export function bootLocalGuest(): GuestApi {
101
+ return Object.freeze({ start, handle, poll });
102
+ }
@@ -1,20 +1,20 @@
1
1
  /** guest 侧限额;与 shared 的 connector-limits 同值(SDK 不依赖 shared)。 */
2
- export declare const PULL_BATCH_MAX_RECORDS = 1000;
3
- export declare const PULL_BATCH_MAX_BYTES: number;
2
+ export const PULL_BATCH_MAX_RECORDS = 1000;
3
+ export const PULL_BATCH_MAX_BYTES = 4 * 1024 * 1024;
4
4
  /** 一次拉取运行里被拒收的记录超过这个数时运行失败(与 shared 的 `PULL_REJECT_THRESHOLD` 同值)。 */
5
- export declare const PULL_REJECT_THRESHOLD = 1000;
6
- export declare const MAX_LOGS = 1000;
7
- export declare const MAX_LOG_MESSAGE = 4096;
5
+ export const PULL_REJECT_THRESHOLD = 1000;
6
+ export const MAX_LOGS = 1000;
7
+ export const MAX_LOG_MESSAGE = 4096;
8
8
  /** 连接器与连接都没声明限流时网关用的并发(`EGRESS_DEFAULT_RATE.concurrency`)。 */
9
- export declare const DEFAULT_CONCURRENCY = 4;
9
+ export const DEFAULT_CONCURRENCY = 4;
10
10
  /** 429 / 网关限流的最多重试次数。 */
11
- export declare const RATE_LIMIT_RETRIES = 5;
11
+ export const RATE_LIMIT_RETRIES = 5;
12
12
  /** 上游 502 / 503 / 504 与网关超时的最多重试次数,退避从 200 ms 起、每次翻倍。 */
13
- export declare const TRANSIENT_RETRIES = 3;
14
- export declare const TRANSIENT_BACKOFF_MS = 200;
13
+ export const TRANSIENT_RETRIES = 3;
14
+ export const TRANSIENT_BACKOFF_MS = 200;
15
15
  /** 429 没给 `Retry-After` 时的等待起点(每次翻倍)。 */
16
- export declare const RATE_LIMIT_BACKOFF_MS = 1000;
16
+ export const RATE_LIMIT_BACKOFF_MS = 1000;
17
17
  /** 单次等待上限,避免来源给出离谱的 `Retry-After`。 */
18
- export declare const MAX_RETRY_WAIT_MS = 600000;
18
+ export const MAX_RETRY_WAIT_MS = 600_000;
19
19
  /** 连接器代码在 vm 里的文件名;失败回报的调用栈只保留这个文件的帧。 */
20
- export declare const CONNECTOR_FILENAME = "connector.js";
20
+ export const CONNECTOR_FILENAME = 'connector.js';
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 加载连接器打包产物。宿主把产物编译成 `(function (module, exports, require) {…})` 交进来(编译在上下文里完成,
3
+ * 函数属于上下文);这里给它一个只认 `@deepexi/datasense-connector` 的 `require`,返回 guest 自己的那份 SDK。
4
+ *
5
+ * 产物按虚拟入口契约导出 `{ descriptor, implementations }`:`descriptor` 是平台校验过的连接器描述(JSON),
6
+ * `implementations` 是各能力目录的默认导出。加载时核对:
7
+ * - 描述的名字与版本等于宿主期望的;
8
+ * - 实现与描述一一对应(数据流、推送、操作、建议列、`check`),缺失与多余的一并列出;
9
+ * - 每个实现是对应的 `define*()` 结果,且名字与所在目录一致;
10
+ * - `idempotency: unsupported` 的操作实现了 `lookup`(否则 `CONNECTOR_LOOKUP_REQUIRED`)。
11
+ */
12
+ import type { ConnectorDescriptor } from '../../descriptor';
13
+ import type { SuggestColumnsFunction } from '../../define';
14
+ import { ConnectorError } from '../../errors';
15
+ import * as sdk from '../../index';
16
+ import type { CheckContext, OperationHandlers, PushHandler, StreamFunction } from '../../types';
17
+ import type { GuestFailureCode } from '../protocol';
18
+ import { errorInfo } from './failure';
19
+
20
+ export const SDK_SPECIFIER = '@deepexi/datasense-connector';
21
+
22
+ const sdkModule = Object.freeze({ ...sdk, __esModule: true });
23
+
24
+ function connectorRequire(specifier: unknown): unknown {
25
+ if (specifier === SDK_SPECIFIER) return sdkModule;
26
+ throw new ConnectorError('CONNECTOR_LOAD_FAILED', `cannot require ${String(specifier)}: connectors can only import ${SDK_SPECIFIER} and relative files`);
27
+ }
28
+
29
+ /** 加载并核对过的连接器:描述加各能力的实现函数。 */
30
+ export interface LoadedConnector {
31
+ descriptor: ConnectorDescriptor;
32
+ check: ((ctx: CheckContext) => void | Promise<void>) | null;
33
+ streams: Record<string, StreamFunction>;
34
+ suggestColumns: Record<string, SuggestColumnsFunction>;
35
+ pushes: Record<string, PushHandler>;
36
+ operations: Record<string, OperationHandlers>;
37
+ }
38
+
39
+ export type LoadOutcome =
40
+ | { ok: true; connector: LoadedConnector }
41
+ | { ok: false; code: Extract<GuestFailureCode, 'CONNECTOR_LOAD_FAILED' | 'CONNECTOR_LOOKUP_REQUIRED'>; message: string; stack: string | undefined };
42
+
43
+ type Factory = (module: { exports: unknown }, exports: unknown, require: (specifier: string) => unknown) => void;
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ function isStringArray(value: unknown): value is string[] {
50
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
51
+ }
52
+
53
+ /** 描述的结构检查(平台打包前已完整校验过,这里只防结构不对时 guest 崩溃);返回问题列表。 */
54
+ function descriptorProblems(value: unknown): string[] {
55
+ if (!isRecord(value)) return ['descriptor must be an object'];
56
+ const problems: string[] = [];
57
+ if (typeof value.name !== 'string' || typeof value.version !== 'string') problems.push('descriptor needs a name and a version');
58
+ if (!isRecord(value.config)) problems.push('descriptor.config must be an object');
59
+ if (!isStringArray(value.hosts)) problems.push('descriptor.hosts must be a list of host names');
60
+ if (value.rateLimit !== null && !(isRecord(value.rateLimit) && typeof value.rateLimit.concurrency === 'number')) problems.push('descriptor.rateLimit must be null or { perSecond, concurrency }');
61
+ if (typeof value.check !== 'boolean') problems.push('descriptor.check must be a boolean');
62
+ for (const key of ['streams', 'pushes', 'operations'] as const) {
63
+ const group = value[key];
64
+ if (!isRecord(group)) {
65
+ problems.push(`descriptor.${key} must be an object`);
66
+ continue;
67
+ }
68
+ for (const [name, item] of Object.entries(group)) {
69
+ if (!isRecord(item)) problems.push(`descriptor.${key}.${name} must be an object`);
70
+ else if (key !== 'operations' && !isRecord(item.tables)) problems.push(`descriptor.${key}.${name}.tables must be an object`);
71
+ else if (key === 'operations' && (!isRecord(item.input) || !isRecord(item.output))) problems.push(`descriptor.operations.${name} needs input and output`);
72
+ }
73
+ }
74
+ return problems;
75
+ }
76
+
77
+ /** 模块命名空间(`{ default }`)取默认导出;已经是实现对象时原样返回。 */
78
+ function implementationOf(value: unknown): unknown {
79
+ if (isRecord(value) && value.__ontos === undefined && Object.hasOwn(value, 'default')) return value.default;
80
+ return value;
81
+ }
82
+
83
+ function brandOf(value: unknown, kind: string, name: string | null): Record<string, unknown> | null {
84
+ if (!isRecord(value) || value.__ontos !== kind) return null;
85
+ if (name !== null && value.name !== name) return null;
86
+ return value;
87
+ }
88
+
89
+ /** 按描述核对实现,返回规整后的连接器或问题。 */
90
+ function matchImplementations(descriptor: ConnectorDescriptor, value: unknown): { connector: LoadedConnector | null; mismatch: string[]; invalid: string[]; lookupMissing: string[] } {
91
+ const implementations = isRecord(value) ? value : {};
92
+ const missing: string[] = [];
93
+ const extra: string[] = [];
94
+ const invalid: string[] = [];
95
+ const lookupMissing: string[] = [];
96
+ const connector: LoadedConnector = { descriptor, check: null, streams: {}, suggestColumns: {}, pushes: {}, operations: {} };
97
+
98
+ const group = (key: string): Record<string, unknown> => {
99
+ const items = implementations[key];
100
+ return isRecord(items) ? items : {};
101
+ };
102
+ /** 一组实现与描述里的名字逐一对照;`label` 是报告里的前缀,`suffix` 是同一目录下的第二种实现(如 suggest-columns)。 */
103
+ const compare = (key: string, expected: readonly string[], label: string, kind: string, file: string, fn: string, accept: (name: string, impl: Record<string, unknown>) => void): void => {
104
+ const items = group(key);
105
+ for (const name of expected) {
106
+ if (!Object.hasOwn(items, name)) {
107
+ missing.push(`${label}/${name}${file === 'index.ts' ? '' : `/${file.replace(/\.ts$/, '')}`}`);
108
+ continue;
109
+ }
110
+ const impl = brandOf(implementationOf(items[name]), kind, name);
111
+ if (!impl) {
112
+ invalid.push(`${label}/${name}/${file} must export default ${fn}('${name}', …)`);
113
+ continue;
114
+ }
115
+ accept(name, impl);
116
+ }
117
+ for (const name of Object.keys(items)) {
118
+ if (!expected.includes(name)) extra.push(`${label}/${name}${file === 'index.ts' ? '' : `/${file.replace(/\.ts$/, '')}`}`);
119
+ }
120
+ };
121
+
122
+ compare('streams', Object.keys(descriptor.streams), 'streams', 'stream', 'index.ts', 'defineStream', (name, impl) => {
123
+ if (typeof impl.run === 'function') connector.streams[name] = impl.run as StreamFunction;
124
+ else invalid.push(`streams/${name}/index.ts must export default defineStream('${name}', …)`);
125
+ });
126
+ const suggestStreams = Object.entries(descriptor.streams).filter(([, stream]) => stream.suggestColumns === true).map(([name]) => name);
127
+ compare('suggestColumns', suggestStreams, 'streams', 'suggest', 'suggest-columns.ts', 'defineSuggestColumns', (name, impl) => {
128
+ if (typeof impl.run === 'function') connector.suggestColumns[name] = impl.run as SuggestColumnsFunction;
129
+ else invalid.push(`streams/${name}/suggest-columns.ts must export default defineSuggestColumns('${name}', …)`);
130
+ });
131
+ compare('pushes', Object.keys(descriptor.pushes), 'pushes', 'push', 'index.ts', 'definePush', (name, impl) => {
132
+ const handler = impl.handler;
133
+ if (isRecord(handler) && typeof handler.map === 'function') connector.pushes[name] = handler as unknown as PushHandler;
134
+ else invalid.push(`pushes/${name}/index.ts must export default definePush('${name}', { map })`);
135
+ });
136
+ compare('operations', Object.keys(descriptor.operations), 'operations', 'operation', 'index.ts', 'defineOperation', (name, impl) => {
137
+ const handlers = impl.handlers;
138
+ if (!isRecord(handlers) || typeof handlers.run !== 'function') {
139
+ invalid.push(`operations/${name}/index.ts must export default defineOperation('${name}', { run })`);
140
+ return;
141
+ }
142
+ connector.operations[name] = handlers as unknown as OperationHandlers;
143
+ if (descriptor.operations[name]?.idempotency === 'unsupported' && typeof handlers.lookup !== 'function') lookupMissing.push(name);
144
+ });
145
+
146
+ const check = implementationOf(implementations.check);
147
+ if (descriptor.check) {
148
+ if (check === undefined || check === null) missing.push('check');
149
+ else {
150
+ const impl = brandOf(check, 'check', null);
151
+ if (impl && typeof impl.run === 'function') connector.check = impl.run as LoadedConnector['check'];
152
+ else invalid.push('check.ts must export default defineCheck(…)');
153
+ }
154
+ } else if (check !== undefined && check !== null) {
155
+ extra.push('check');
156
+ }
157
+
158
+ const mismatch = [...missing.map((item) => `missing ${item}`), ...extra.map((item) => `extra ${item}`)];
159
+ return { connector: mismatch.length === 0 && invalid.length === 0 && lookupMissing.length === 0 ? connector : null, mismatch, invalid, lookupMissing };
160
+ }
161
+
162
+ /** 执行连接器代码、取 `{ descriptor, implementations }`,核对期望的名字与版本、实现与描述是否一致。 */
163
+ export function loadConnector(factory: unknown, expect: { name: string; version: string }): LoadOutcome {
164
+ if (typeof factory !== 'function') return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: 'connector code did not compile to a module', stack: undefined };
165
+ let exported: unknown;
166
+ try {
167
+ const module = { exports: {} as unknown };
168
+ Reflect.apply(factory as Factory, undefined, [module, module.exports, connectorRequire]);
169
+ exported = module.exports;
170
+ } catch (error) {
171
+ const info = errorInfo(error);
172
+ return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: `connector code failed to load: ${info.message}`, stack: info.stack };
173
+ }
174
+ if (!isRecord(exported) && typeof exported !== 'function') {
175
+ return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: 'connector bundle must export { descriptor, implementations }', stack: undefined };
176
+ }
177
+ let descriptor: unknown;
178
+ let implementations: unknown;
179
+ try {
180
+ descriptor = (exported as { descriptor?: unknown }).descriptor;
181
+ implementations = (exported as { implementations?: unknown }).implementations;
182
+ } catch (error) {
183
+ return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: `connector code failed to load: ${errorInfo(error).message}`, stack: undefined };
184
+ }
185
+ const problems = descriptorProblems(descriptor);
186
+ if (problems.length > 0) return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: `invalid connector descriptor: ${problems.join('; ')}`, stack: undefined };
187
+ const loaded = descriptor as ConnectorDescriptor;
188
+ if (loaded.name !== expect.name || loaded.version !== expect.version) {
189
+ return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: `connector bundle is ${loaded.name}@${loaded.version}, expected ${expect.name}@${expect.version}`, stack: undefined };
190
+ }
191
+ if (!isRecord(implementations)) {
192
+ return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: 'connector bundle must export implementations', stack: undefined };
193
+ }
194
+ const matched = matchImplementations(loaded, implementations);
195
+ if (matched.mismatch.length > 0 || matched.invalid.length > 0) {
196
+ const parts = [];
197
+ if (matched.mismatch.length > 0) parts.push(`implementations do not match the connector descriptor: ${matched.mismatch.join('; ')}`);
198
+ if (matched.invalid.length > 0) parts.push(matched.invalid.join('; '));
199
+ return { ok: false, code: 'CONNECTOR_LOAD_FAILED', message: parts.join('; '), stack: undefined };
200
+ }
201
+ if (matched.lookupMissing.length > 0) {
202
+ return {
203
+ ok: false,
204
+ code: 'CONNECTOR_LOOKUP_REQUIRED',
205
+ message: `operations with idempotency 'unsupported' must implement lookup(ctx, { idempotencyKey, params }) so the platform can confirm uncertain results: ${matched.lookupMissing.join(', ')}`,
206
+ stack: undefined,
207
+ };
208
+ }
209
+ return { ok: true, connector: matched.connector as LoadedConnector };
210
+ }
@@ -0,0 +1,47 @@
1
+ /** `ctx.log`:一次运行最多保留 1000 条,单条超过 4096 字符截断;拉取作业每批 `emit` 带走已有的日志。 */
2
+ import type { ConnectorLog } from '../../types';
3
+ import type { GuestLog } from '../protocol';
4
+ import { jsonStringify } from './bridge';
5
+ import { MAX_LOG_MESSAGE, MAX_LOGS } from './limits';
6
+ import { isoNow } from './time';
7
+
8
+ export interface LogBuffer {
9
+ /** 取走尚未上报的日志。 */
10
+ drain(): GuestLog[];
11
+ /** `isOpen` 返回 false 时丢弃(推送请求结束后再写日志无效)。 */
12
+ logger(requestId?: string, isOpen?: () => boolean): ConnectorLog;
13
+ }
14
+
15
+ function render(message: unknown, data: unknown): string {
16
+ let text = typeof message === 'string' ? message : 'non-string log message';
17
+ if (data !== undefined) {
18
+ try {
19
+ const extra = jsonStringify(data);
20
+ if (typeof extra === 'string') text = `${text} ${extra}`;
21
+ } catch {
22
+ // 附加数据不可序列化时只记文案
23
+ }
24
+ }
25
+ return text.length > MAX_LOG_MESSAGE ? text.slice(0, MAX_LOG_MESSAGE) : text;
26
+ }
27
+
28
+ export function createLogBuffer(): LogBuffer {
29
+ let entries: GuestLog[] = [];
30
+ let total = 0;
31
+ const write = (level: GuestLog['level'], requestId: string | undefined, isOpen: (() => boolean) | undefined) =>
32
+ (message: string, data?: Record<string, unknown>): void => {
33
+ if (total >= MAX_LOGS || (isOpen && !isOpen())) return;
34
+ total += 1;
35
+ entries[entries.length] = { at: isoNow(), level, message: render(message, data), ...(requestId !== undefined ? { requestId } : {}) };
36
+ };
37
+ return {
38
+ drain() {
39
+ const out = entries;
40
+ entries = [];
41
+ return out;
42
+ },
43
+ logger(requestId, isOpen) {
44
+ return Object.freeze({ info: write('info', requestId, isOpen), warn: write('warn', requestId, isOpen), error: write('error', requestId, isOpen) });
45
+ },
46
+ };
47
+ }