Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions scripts/smoke-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}.`);
}

Expand Down
30 changes: 14 additions & 16 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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') {
Expand Down
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
136 changes: 123 additions & 13 deletions src/misc/environment.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,141 @@
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<T>(specifier: string): Promise<T> {
const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<T>;
return dynamicImport(specifier);
}

function toNodeRequestHeaders(headers: RequestInit['headers']): Record<string, string> {
const result: Record<string, string> = {};
new Headers(headers).forEach((value, key) => {
result[key] = value;
});
return result;
}

function toResponseHeaders(headers: NodeJS.Dict<string | string[] | number>): 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<string | Uint8Array | undefined> {
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<ArrayBuffer> = 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<Response> {
const parsed = new URL(url);
const transport = parsed.protocol === 'https:'
? await importNodeModule<NodeHttps>('node:https')
: await importNodeModule<NodeHttp>('node:http');
const body = await toNodeBody(init.body);

return new Promise<Response>((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()) {
throw new ZentaoError('E_INSECURE_BROWSER');
}
}

/** 在 Node.js 中临时关闭 TLS 校验,并在本次请求结束后恢复原值。 */
/** 发起 fetch 请求;Node.js 下的 `insecure` 只作用于当前 HTTPS 请求。 */
export async function fetchWithInsecureTls(
enabled: boolean | undefined,
url: string,
init: RequestInit,
): Promise<Response> {
if (!enabled) return fetch(url, init);
assertInsecureSupported(enabled);
return nodeFetchWithTlsOptions(url, init, false);
}

/** 保留给内部测试和兼容调用:校验 TLS 选项,但不再改写进程级环境变量。 */
export async function withInsecureTls<T>(enabled: boolean | undefined, fn: () => Promise<T>): Promise<T> {
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();
}
30 changes: 22 additions & 8 deletions src/modules/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,27 @@ export interface DefineModulesOptions {
let modules = cloneModules(BUILTIN_MODULES);
let moduleMap = buildModuleMap(modules);

function cloneValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map(cloneValue) as T;
}
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {};
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),
}));
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -115,20 +129,20 @@ export function getModule(moduleName: string): ModuleDefinition {
if (!module) {
throw new ZentaoError('E_INVALID_MODULE', { module: moduleName });
}
return module;
return cloneModules([module])[0];
}

/** 获取指定模块动作;`ls` 会作为 `list` 的别名处理。 */
export function getModuleAction(moduleName: string, actionName: string): ModuleAction {
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 });
Expand Down
16 changes: 13 additions & 3 deletions src/modules/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 13 additions & 5 deletions tests/client.edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -130,15 +138,15 @@ 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 {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';

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) {
Expand Down
Loading
Loading