diff --git a/scripts/smoke-package.ts b/scripts/smoke-package.ts index f80bd69..330c23e 100644 --- a/scripts/smoke-package.ts +++ b/scripts/smoke-package.ts @@ -21,10 +21,17 @@ function readPackageJson(): { } const packageJson = readPackageJson(); +const browserExport = packageJson.exports['./browser']; +assert(typeof browserExport === 'string', 'package.json must define a string ./browser export.'); + +function toPackedPath(file: string): string { + return file.replace(/^\.\//, ''); +} + const requiredFiles = [ packageJson.main, packageJson.types, - './dist/browser/zentao-api.global.js', + browserExport, ]; for (const file of requiredFiles) { @@ -55,7 +62,13 @@ const packOutput = execFileSync('npm', ['pack', '--dry-run', '--json'], { const [pack] = JSON.parse(packOutput) as Array<{ files: Array<{ path: string }> }>; const packedFiles = new Set(pack.files.map((file) => file.path)); -for (const file of ['dist/index.js', 'dist/index.d.ts', 'dist/browser/zentao-api.global.js', 'README.md', 'LICENSE']) { +for (const file of [ + toPackedPath(packageJson.main), + toPackedPath(packageJson.types), + toPackedPath(browserExport), + 'README.md', + 'LICENSE', +]) { assert(packedFiles.has(file), `Packed tarball is missing ${file}.`); } diff --git a/src/client/index.ts b/src/client/index.ts index 63d539f..dc8759e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,5 @@ import { ZentaoError } from '../misc/errors.js'; -import { assertInsecureSupported, withInsecureTls } from '../misc/environment.js'; +import { assertInsecureSupported, fetchWithInsecureTls } from '../misc/environment.js'; import { getGlobalOptions, setGlobalOptions } from '../misc/global-options.js'; import { addProfile, switchProfile } from '../profiles/index.js'; import { isRecord, normalizeSiteUrl } from '../utils/index.js'; @@ -96,21 +96,19 @@ export class ZentaoClient { } try { - return await withInsecureTls(insecure, async () => { - const response = await fetch(url, init); - if (!response.ok) { - throw new ZentaoError('E_HTTP_ERROR', { - status: response.status, - statusText: response.statusText, - }, { - url: response.url, - status: response.status, - statusText: response.statusText, - body: await response.text().catch(() => undefined), - }); - } - return parseResponse(response); - }); + const response = await fetchWithInsecureTls(insecure, url, init); + if (!response.ok) { + throw new ZentaoError('E_HTTP_ERROR', { + status: response.status, + statusText: response.statusText, + }, { + url: response.url, + status: response.status, + statusText: response.statusText, + body: await response.text().catch(() => undefined), + }); + } + return parseResponse(response); } catch (error) { if (error instanceof ZentaoError) throw error; if (error instanceof DOMException && error.name === 'AbortError') { diff --git a/src/index.ts b/src/index.ts index ca897b1..09f3b61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,12 +4,10 @@ export { getGlobalOptions, setGlobalOptions } from './misc/global-options.js'; export { ZENTAO_PROFILES_STORAGE_KEY, addProfile, - addProfle, deleteProfile, getAllProfiles, getProfile, getProfileKey, - getProfle, switchProfile, } from './profiles/index.js'; export { diff --git a/src/misc/environment.ts b/src/misc/environment.ts index c450322..4e70d99 100644 --- a/src/misc/environment.ts +++ b/src/misc/environment.ts @@ -1,10 +1,120 @@ import { ZentaoError } from './errors.js'; +type NodeHttp = typeof import('node:http'); +type NodeHttps = typeof import('node:https'); + /** 判断当前运行时是否为 Node.js。 */ export function isNodeRuntime(): boolean { return typeof process !== 'undefined' && Boolean(process.versions?.node); } +async function importNodeModule(specifier: string): Promise { + const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; + return dynamicImport(specifier); +} + +function toNodeRequestHeaders(headers: RequestInit['headers']): Record { + const result: Record = {}; + new Headers(headers).forEach((value, key) => { + result[key] = value; + }); + return result; +} + +function toResponseHeaders(headers: NodeJS.Dict): Headers { + const result = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + result.set(key, Array.isArray(value) ? value.join(', ') : String(value)); + } + return result; +} + +async function toNodeBody(body: BodyInit | null | undefined): Promise { + if (body === undefined || body === null) return undefined; + if (typeof body === 'string') return body; + if (body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + } + if (body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()); + } + return String(body); +} + +function abortError(): DOMException { + return new DOMException('The operation was aborted.', 'AbortError'); +} + +function concatenateChunks(chunks: Uint8Array[]): ArrayBuffer { + const totalLength = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const result: Uint8Array = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result.buffer; +} + +async function nodeFetchWithTlsOptions(url: string, init: RequestInit, rejectUnauthorized: boolean): Promise { + const parsed = new URL(url); + const transport = parsed.protocol === 'https:' + ? await importNodeModule('node:https') + : await importNodeModule('node:http'); + const body = await toNodeBody(init.body); + + return new Promise((resolve, reject) => { + if (init.signal?.aborted) { + reject(abortError()); + return; + } + + const request = transport.request(parsed, { + method: init.method ?? 'GET', + headers: toNodeRequestHeaders(init.headers), + rejectUnauthorized, + }, (response) => { + const chunks: Uint8Array[] = []; + + response.on('data', (chunk: Uint8Array | string) => { + chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk); + }); + + response.on('end', () => { + cleanup(); + const responseBody = chunks.length > 0 ? concatenateChunks(chunks) : undefined; + const fetchResponse = new Response(responseBody, { + status: response.statusCode ?? 200, + statusText: response.statusMessage ?? '', + headers: toResponseHeaders(response.headers), + }); + Object.defineProperty(fetchResponse, 'url', { value: url }); + resolve(fetchResponse); + }); + }); + + const cleanup = () => { + init.signal?.removeEventListener('abort', abortHandler); + }; + const abortHandler = () => { + cleanup(); + request.destroy(abortError()); + }; + + request.on('error', (error) => { + cleanup(); + reject(error); + }); + + init.signal?.addEventListener('abort', abortHandler, { once: true }); + if (body !== undefined) request.write(body); + request.end(); + }); +} + /** 浏览器无法跳过 TLS 校验,因此在发起请求前提前失败。 */ export function assertInsecureSupported(enabled: boolean | undefined): void { if (enabled && !isNodeRuntime()) { @@ -12,20 +122,20 @@ export function assertInsecureSupported(enabled: boolean | undefined): void { } } -/** 在 Node.js 中临时关闭 TLS 校验,并在本次请求结束后恢复原值。 */ +/** 发起 fetch 请求;Node.js 下的 `insecure` 只作用于当前 HTTPS 请求。 */ +export async function fetchWithInsecureTls( + enabled: boolean | undefined, + url: string, + init: RequestInit, +): Promise { + if (!enabled) return fetch(url, init); + assertInsecureSupported(enabled); + return nodeFetchWithTlsOptions(url, init, false); +} + +/** 保留给内部测试和兼容调用:校验 TLS 选项,但不再改写进程级环境变量。 */ export async function withInsecureTls(enabled: boolean | undefined, fn: () => Promise): Promise { if (!enabled) return fn(); assertInsecureSupported(enabled); - - const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - try { - return await fn(); - } finally { - if (previous === undefined) { - delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - } else { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous; - } - } + return fn(); } diff --git a/src/modules/registry.ts b/src/modules/registry.ts index c275d5e..d24ed06 100644 --- a/src/modules/registry.ts +++ b/src/modules/registry.ts @@ -15,13 +15,27 @@ export interface DefineModulesOptions { let modules = cloneModules(BUILTIN_MODULES); let moduleMap = buildModuleMap(modules); +function cloneValue(value: T): T { + if (Array.isArray(value)) { + return value.map(cloneValue) as T; + } + if (value && typeof value === 'object') { + const result: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + result[key] = cloneValue(nestedValue); + } + return result as T; + } + return value; +} + function cloneActions(source: readonly ModuleAction[]): ModuleAction[] { - return source.map((action) => ({ ...action })); + return source.map((action) => cloneValue(action)); } function cloneModules(source: readonly ModuleDefinition[]): ModuleDefinition[] { return source.map((module) => ({ - ...module, + ...cloneValue(module), actions: cloneActions(module.actions), })); } @@ -35,7 +49,7 @@ function mergeActions(base: readonly ModuleAction[], extension: readonly ModuleA const next = cloneActions(base); for (const action of extension) { const index = findActionIndex(next, String(action.name)); - const clone = { ...action }; + const clone = cloneValue(action); if (index >= 0) { next[index] = clone; } else { @@ -79,7 +93,7 @@ export function defineModules(input: ModuleDefinition | ModuleDefinition[], opti validateModule(module); const key = module.name.toLowerCase(); const index = modules.findIndex((item) => item.name.toLowerCase() === key); - const next = { ...module, actions: cloneActions(module.actions) }; + const next = { ...cloneValue(module), actions: cloneActions(module.actions) }; if (index >= 0) { modules[index] = options.replace ? next : mergeModule(modules[index], module); } else { @@ -99,7 +113,7 @@ export function defineModuleActions(moduleName: string, input: ModuleAction | Mo for (const action of asArray(input)) { validateAction(action); const index = findActionIndex(module.actions, String(action.name)); - const next = { ...action }; + const next = cloneValue(action); // 同名动作替换,未知动作追加;不做深度合并,避免 schema/数组字段出现隐式规则。 if (index >= 0) { module.actions[index] = next; @@ -115,7 +129,7 @@ export function getModule(moduleName: string): ModuleDefinition { if (!module) { throw new ZentaoError('E_INVALID_MODULE', { module: moduleName }); } - return module; + return cloneModules([module])[0]; } /** 获取指定模块动作;`ls` 会作为 `list` 的别名处理。 */ @@ -123,12 +137,12 @@ export function getModuleAction(moduleName: string, actionName: string): ModuleA const module = getModule(moduleName); const normalized = actionName === 'ls' ? 'list' : actionName; const direct = module.actions.find((action) => String(action.name).toLowerCase() === normalized.toLowerCase()); - if (direct) return direct; + if (direct) return cloneValue(direct); const crud = new Set(['list', 'get', 'create', 'update', 'delete']); if (!crud.has(normalized)) { const custom = module.actions.find((action) => action.type === 'action' && String(action.name).toLowerCase() === normalized.toLowerCase()); - if (custom) return custom; + if (custom) return cloneValue(custom); } throw new ZentaoError('E_INVALID_ACTION', { module: moduleName, action: actionName }); diff --git a/src/modules/resolve.ts b/src/modules/resolve.ts index 77bded8..0edd784 100644 --- a/src/modules/resolve.ts +++ b/src/modules/resolve.ts @@ -58,9 +58,19 @@ function coerceValue(value: unknown, type?: string): unknown { return Number.isNaN(numberValue) ? value : numberValue; } if (type === 'boolean') { - if (value === 'true') return true; - if (value === 'false') return false; - return Boolean(value); + if (typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (value === 1) return true; + if (value === 0) return false; + return value; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + return value; + } + return value; } return value; } diff --git a/tests/client.edge.test.ts b/tests/client.edge.test.ts index 8b84d33..8942429 100644 --- a/tests/client.edge.test.ts +++ b/tests/client.edge.test.ts @@ -111,17 +111,25 @@ describe('ZentaoClient edge cases', () => { }); describe('insecure TLS environment handling', () => { - test('withInsecureTls restores an unset NODE_TLS_REJECT_UNAUTHORIZED value', async () => { + test('insecure requests do not mutate NODE_TLS_REJECT_UNAUTHORIZED while pending', async () => { const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + let valueDuringRequest: string | undefined; + const server = createMockServer(async () => { + valueDuringRequest = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + return Response.json({ status: 'success' }); + }); try { delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - const valueDuringRequest = await withInsecureTls(true, async () => process.env.NODE_TLS_REJECT_UNAUTHORIZED); + const client = new ZentaoClient({ baseUrl: server.url.toString() }); + await client.get('/products'); + await client.request('/products', { insecure: true }); - expect(valueDuringRequest).toBe('0'); + expect(valueDuringRequest).toBeUndefined(); expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); } finally { + server.stop(); if (previous === undefined) { delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; } else { @@ -130,7 +138,7 @@ describe('insecure TLS environment handling', () => { } }); - test('withInsecureTls restores an existing NODE_TLS_REJECT_UNAUTHORIZED value', async () => { + test('withInsecureTls leaves existing NODE_TLS_REJECT_UNAUTHORIZED values untouched', async () => { const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED; try { @@ -138,7 +146,7 @@ describe('insecure TLS environment handling', () => { const valueDuringRequest = await withInsecureTls(true, async () => process.env.NODE_TLS_REJECT_UNAUTHORIZED); - expect(valueDuringRequest).toBe('0'); + expect(valueDuringRequest).toBe('1'); expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe('1'); } finally { if (previous === undefined) { diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 4d9e69f..f01abaf 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -128,6 +128,18 @@ describe('module registry', () => { expect(() => getModule('missing')).toThrow('module'); expect(() => getModuleAction('product', 'missing')).toThrow('action'); }); + + test('getModule and getModuleAction do not expose mutable registry internals', () => { + const module = getModule('product'); + module.actions.length = 0; + + expect(getModuleAction('product', 'list').path).toBe('/products'); + + const action = getModuleAction('product', 'list'); + action.path = '/mutated-products'; + + expect(getModuleAction('product', 'list').path).toBe('/products'); + }); }); describe('high-level request', () => { diff --git a/tests/package.test.ts b/tests/package.test.ts index ee14ca1..2341732 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -29,4 +29,18 @@ describe('package exports', () => { expect(packageJson.scripts.check).toContain('smoke:package'); expect(packageJson.scripts.prepublishOnly).toBe('bun run check'); }); + + test('prevents partial build artifacts when type checking fails', () => { + const tsconfig = JSON.parse(readFileSync(join(process.cwd(), 'tsconfig.json'), 'utf8')) as { + compilerOptions: Record; + }; + + expect(tsconfig.compilerOptions.noEmitOnError).toBe(true); + }); + + test('package smoke test verifies the configured browser export target', () => { + const smokePackageScript = readFileSync(join(process.cwd(), 'scripts/smoke-package.ts'), 'utf8'); + + expect(smokePackageScript).toContain("packageJson.exports['./browser']"); + }); }); diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index ea32dfa..2da9f47 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -138,6 +138,44 @@ describe('resolveModuleCommand', () => { }); }); + test('coerces common boolean string values without treating every non-empty string as true', () => { + defineModules({ + name: 'flagform', + actions: [ + { + name: 'create', + type: 'create', + method: 'post', + path: '/flag-forms', + resultType: 'object', + requestBody: { + type: 'object', + schema: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + archived: { type: 'boolean' }, + visible: { type: 'boolean' }, + }, + }, + }, + }, + ], + }); + + const command = resolveModuleCommand(getModule('flagform'), 'create', { + enabled: '0', + archived: 'off', + visible: '1', + }); + + expect(command.data).toEqual({ + enabled: false, + archived: false, + visible: true, + }); + }); + test('preserves explicit object values from data for array schema fields', () => { defineModules({ name: 'iteration', diff --git a/tsconfig.json b/tsconfig.json index 4b9915d..04f4a16 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, + "noEmitOnError": true, "outDir": "./dist", "rootDir": "./src", "lib": ["ES2022", "DOM"],