diff --git a/auto.ts b/auto.ts new file mode 100644 index 0000000..bef4e85 --- /dev/null +++ b/auto.ts @@ -0,0 +1,20 @@ +import { loadTS, getMatchFiles } from './src/auto/load-ts.ts'; +import { listenSocket } from './src/auto/listen-sock.ts'; +import { Route, QueryRouter, QueryRouterServer } from './src/route.ts'; + +export { Route, QueryRouter, QueryRouterServer }; + +export const App = QueryRouterServer; + +export { createSchema } from './src/validator/index.ts'; +export type { Rule } from './src/validator/rule.ts'; +export type { Schema } from 'zod'; +export type { RouteContext, RouteOpts } from './src/route.ts'; + +export type { Run } from './src/route.ts'; + +export { CustomError } from './src/result/error.ts'; + +export { listenSocket, loadTS, getMatchFiles }; + +export { autoCall } from './src/auto/call-sock.ts'; diff --git a/src/auto/call-sock.ts b/src/auto/call-sock.ts new file mode 100644 index 0000000..d7c83b5 --- /dev/null +++ b/src/auto/call-sock.ts @@ -0,0 +1,164 @@ +import { createConnection } from 'node:net'; + +type QueryData = { + path?: string; + key?: string; + payload?: any; + [key: string]: any; +}; + +type CallSockOptions = { + socketPath?: string; + timeout?: number; + method?: 'GET' | 'POST'; +}; + +export const callSock = async (data: QueryData, options: CallSockOptions = {}): Promise => { + const { socketPath = './app.sock', timeout = 10000, method = 'POST' } = options; + + return new Promise((resolve, reject) => { + const client = createConnection(socketPath); + let responseData = ''; + let timer: NodeJS.Timeout; + + // 设置超时 + if (timeout > 0) { + timer = setTimeout(() => { + client.destroy(); + reject(new Error(`Socket call timeout after ${timeout}ms`)); + }, timeout); + } + + client.on('connect', () => { + try { + let request: string; + + if (method === 'GET') { + // GET 请求:参数放在 URL 中 + const searchParams = new URLSearchParams(); + Object.entries(data).forEach(([key, value]) => { + if (key === 'payload' && typeof value === 'object') { + searchParams.append(key, JSON.stringify(value)); + } else { + searchParams.append(key, String(value)); + } + }); + + const queryString = searchParams.toString(); + const url = queryString ? `/?${queryString}` : '/'; + + request = [`GET ${url} HTTP/1.1`, 'Host: localhost', 'Connection: close', '', ''].join('\r\n'); + } else { + // POST 请求:数据放在 body 中 + const body = JSON.stringify(data); + const contentLength = Buffer.byteLength(body, 'utf8'); + + request = [ + 'POST / HTTP/1.1', + 'Host: localhost', + 'Content-Type: application/json', + `Content-Length: ${contentLength}`, + 'Connection: close', + '', + body, + ].join('\r\n'); + } + + client.write(request); + } catch (error) { + if (timer) clearTimeout(timer); + client.destroy(); + reject(error); + } + }); + + client.on('data', (chunk) => { + responseData += chunk.toString(); + + // 检查是否收到完整的HTTP响应 + if (responseData.includes('\r\n\r\n')) { + const [headerSection] = responseData.split('\r\n\r\n'); + const contentLengthMatch = headerSection.match(/content-length:\s*(\d+)/i); + + if (contentLengthMatch) { + const expectedLength = parseInt(contentLengthMatch[1]); + const bodyStart = responseData.indexOf('\r\n\r\n') + 4; + const currentBodyLength = Buffer.byteLength(responseData.slice(bodyStart), 'utf8'); + + // 如果收到了完整的响应,主动关闭连接 + if (currentBodyLength >= expectedLength) { + client.end(); + } + } else if (responseData.includes('\r\n0\r\n\r\n')) { + // 检查 chunked 编码结束标记 + client.end(); + } + } + }); + + client.on('end', () => { + if (timer) clearTimeout(timer); + + try { + // 解析 HTTP 响应 + const response = parseHttpResponse(responseData); + + if (response.statusCode >= 400) { + reject(new Error(`HTTP ${response.statusCode}: ${response.body}`)); + return; + } + + // 尝试解析 JSON 响应 + try { + const result = JSON.parse(response.body); + resolve(result); + } catch { + // 如果不是 JSON,直接返回文本 + resolve(response.body); + } + } catch (error) { + reject(error); + } + }); + + client.on('error', (error) => { + if (timer) clearTimeout(timer); + reject(error); + }); + + client.on('timeout', () => { + if (timer) clearTimeout(timer); + client.destroy(); + reject(new Error('Socket connection timeout')); + }); + }); +}; + +// 解析 HTTP 响应的辅助函数 +function parseHttpResponse(responseData: string) { + const [headerSection, ...bodyParts] = responseData.split('\r\n\r\n'); + const body = bodyParts.join('\r\n\r\n'); + + const lines = headerSection.split('\r\n'); + const statusLine = lines[0]; + const statusMatch = statusLine.match(/HTTP\/\d\.\d (\d+)/); + const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200; + + const headers: Record = {}; + for (let i = 1; i < lines.length; i++) { + const [key, ...valueParts] = lines[i].split(':'); + if (key && valueParts.length > 0) { + headers[key.trim().toLowerCase()] = valueParts.join(':').trim(); + } + } + + return { + statusCode, + headers, + body: body || '', + }; +} + +export const autoCall = (data: QueryData, options?: Omit) => { + return callSock(data, { ...options, method: 'POST' }); +}; diff --git a/src/auto/listen-sock.ts b/src/auto/listen-sock.ts new file mode 100644 index 0000000..8f32bbe --- /dev/null +++ b/src/auto/listen-sock.ts @@ -0,0 +1,274 @@ +import type { IncomingMessage } from 'http'; +import { QueryRouterServer } from '../route.ts'; +import { getRuntime } from './runtime.ts'; +import { runFirstCheck } from './listen/run-check.ts'; +import { cleanup } from './listen/cleanup.ts'; +import { ServerTimer } from './listen/server-time.ts'; + +type ListenSocketOptions = { + /** + * Unix socket path, defaults to './app.sock' + */ + path?: string; + app?: QueryRouterServer; + /** + * Unix socket path, defaults to './app.pid' + */ + pidPath?: string; + /** + * Timeout for the server, defaults to 15 minutes. + * If the server is not responsive for this duration, it will be terminated + */ + timeout?: number; +}; + +const server = async (req, app: QueryRouterServer) => { + const runtime = getRuntime(); + let data; + if (!runtime.isNode) { + data = await getRequestParams(req); + } else { + data = await parseBody(req); + } + // @ts-ignore + const serverTimer = app.serverTimer; + if (serverTimer) { + serverTimer?.run?.(); + } + const result = await app.queryRoute(data as any); + const response = new Response(JSON.stringify(result)); + response.headers.set('Content-Type', 'application/json'); + return response; +}; +export const closeListenSocket = () => { + console.log('Closing listen socket'); + process.emit('SIGINT'); +}; +export const serverTimer = new ServerTimer(); +export const listenSocket = async (options?: ListenSocketOptions) => { + const path = options?.path || './app.sock'; + const pidPath = options?.pidPath || './app.pid'; + const timeout = options?.timeout || 24 * 60 * 60 * 1000; // 24 hours + const runtime = getRuntime(); + + serverTimer.timeout = timeout; + serverTimer.startTimer(); + serverTimer.onTimeout = closeListenSocket; + + let app = options?.app || globalThis.context?.app; + if (!app) { + app = new QueryRouterServer(); + } + app.serverTimer = serverTimer; + await runFirstCheck(path, pidPath); + let close = async () => {}; + cleanup({ path, close }); + if (runtime.isDeno) { + // 检查 Deno 版本是否支持 Unix domain socket + try { + // @ts-ignore + const listener = Deno.listen({ + transport: 'unix', + path: path, + }); + + // 处理连接 + (async () => { + for await (const conn of listener) { + (async () => { + // @ts-ignore + const httpConn = Deno.serveHttp(conn); + for await (const requestEvent of httpConn) { + try { + const response = await server(requestEvent.request, app); + await requestEvent.respondWith(response); + } catch (error) { + await requestEvent.respondWith(new Response('Internal Server Error', { status: 500 })); + } + } + })(); + } + })(); + close = async () => { + listener.close(); + }; + return listener; + } catch (error) { + // 如果 Unix socket 不支持,回退到 HTTP 服务器 + console.warn('Unix socket not supported in this Deno environment, falling back to HTTP server'); + + // @ts-ignore + const listener = Deno.listen({ port: 0 }); // 使用随机端口 + + // @ts-ignore + console.log(`Deno server listening on port ${listener.addr.port}`); + + (async () => { + for await (const conn of listener) { + (async () => { + // @ts-ignore + const httpConn = Deno.serveHttp(conn); + for await (const requestEvent of httpConn) { + try { + const response = await server(requestEvent.request, app); + await requestEvent.respondWith(response); + } catch (error) { + await requestEvent.respondWith(new Response('Internal Server Error', { status: 500 })); + } + } + })(); + } + })(); + + return listener; + } + } + + if (runtime.isBun) { + // @ts-ignore + const bunServer = Bun.serve({ + unix: path, + fetch(req) { + return server(req, app); + }, + }); + close = async () => { + await bunServer.stop(); + }; + return bunServer; + } + + // Node.js 环境 + const http = await import('http'); + + const httpServer = http.createServer(async (req, res) => { + try { + const response = await server(req, app); + + // 设置响应头 + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + // 设置状态码 + res.statusCode = response.status; + + // 读取响应体并写入 + const body = await response.text(); + res.end(body); + } catch (error) { + console.error('Error handling request:', error); + res.statusCode = 500; + res.end('Internal Server Error'); + } + }); + + httpServer.listen(path); + close = async () => { + httpServer.close(); + }; + return httpServer; +}; + +export const getRequestParams = async (req: Request) => { + let urlParams: Record = {}; + let bodyParams: Record = {}; + + // 获取URL参数 + const url = new URL(req.url); + for (const [key, value] of url.searchParams.entries()) { + // 尝试解析JSON payload + if (key === 'payload') { + try { + urlParams[key] = JSON.parse(value); + } catch { + urlParams[key] = value; + } + } else { + urlParams[key] = value; + } + } + + // 获取body参数 + if (req.method.toLowerCase() === 'post' && req.body) { + const contentType = req.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + try { + bodyParams = await req.json(); + } catch { + // 如果解析失败,保持空对象 + } + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await req.text(); + const params = new URLSearchParams(formData); + for (const [key, value] of params.entries()) { + bodyParams[key] = value; + } + } else if (contentType.includes('multipart/form-data')) { + try { + const formData = await req.formData(); + for (const [key, value] of formData.entries()) { + // @ts-ignore + bodyParams[key] = value instanceof File ? value : value.toString(); + } + } catch { + // 如果解析失败,保持空对象 + } + } + } + + // body参数优先,合并数据 + return { + ...urlParams, + ...bodyParams, + }; +}; + +export const parseBody = async >(req: IncomingMessage) => { + return new Promise((resolve, reject) => { + const arr: any[] = []; + req.on('data', (chunk) => { + arr.push(chunk); + }); + req.on('end', () => { + try { + const body = Buffer.concat(arr).toString(); + + // 获取 Content-Type 头信息 + const contentType = req.headers['content-type'] || ''; + + // 处理 application/json + if (contentType.includes('application/json')) { + resolve(JSON.parse(body) as T); + return; + } + // 处理 application/x-www-form-urlencoded + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = new URLSearchParams(body); + const result: Record = {}; + + formData.forEach((value, key) => { + // 尝试将值解析为 JSON,如果失败则保留原始字符串 + try { + result[key] = JSON.parse(value); + } catch { + result[key] = value; + } + }); + + resolve(result as T); + return; + } + + // 默认尝试 JSON 解析 + try { + resolve(JSON.parse(body) as T); + } catch { + resolve({} as T); + } + } catch (e) { + resolve({} as T); + } + }); + }); +}; diff --git a/src/auto/listen/cleanup.ts b/src/auto/listen/cleanup.ts new file mode 100644 index 0000000..7610dee --- /dev/null +++ b/src/auto/listen/cleanup.ts @@ -0,0 +1,102 @@ +import { getRuntime } from '../runtime.ts'; + +let isClean = false; +export const deleteFileDetached = async (path: string, pidPath: string = './app.pid') => { + const runtime = getRuntime(); + if (runtime.isDeno) { + // Deno 实现 - 启动后不等待结果 + const process = new Deno.Command('sh', { + args: ['-c', `rm -f "${path}" & rm -f "${pidPath}"`], + stdout: 'null', + stderr: 'null', + }); + process.spawn(); // 不等待结果 + console.log(`[DEBUG] Fire-and-forget delete initiated for ${path}`); + return; + } + const { spawn } = await import('node:child_process'); + const child = spawn('sh', ['-c', `rm -f "${path}" & rm -f "${pidPath}"`], { + detached: true, + stdio: 'ignore', + }); + child.unref(); // 完全分离 + console.log(`[DEBUG] Fire-and-forget delete initiated for ${path}`); +}; + +type CleanupOptions = { + path: string; + close?: () => Promise; + pidPath?: string; +}; +export const cleanup = async ({ path, close = async () => {}, pidPath = './app.pid' }: CleanupOptions) => { + const runtime = getRuntime(); + + // 检查文件是否存在并删除 + const cleanupFile = async () => { + if (isClean) return; + isClean = true; + if (runtime.isDeno) { + await deleteFileDetached(path, pidPath); + } + await close(); + if (!runtime.isDeno) { + await deleteFileDetached(path, pidPath); + } + }; + + // 根据运行时环境注册不同的退出监听器 + if (runtime.isDeno) { + // Deno 环境 + const handleSignal = () => { + cleanupFile(); + Deno.exit(0); + }; + + try { + Deno.addSignalListener('SIGINT', handleSignal); + Deno.addSignalListener('SIGTERM', handleSignal); + } catch (error) { + console.warn('[DEBUG] Failed to add signal listeners:', error); + } + + // 对于 beforeunload 和 unload,使用异步清理 + const handleUnload = () => { + cleanupFile(); + }; + + globalThis.addEventListener('beforeunload', handleUnload); + globalThis.addEventListener('unload', handleUnload); + } else if (runtime.isNode || runtime.isBun) { + // Node.js 和 Bun 环境 + import('process').then(({ default: process }) => { + // 信号处理使用同步清理,然后退出 + const signalHandler = async (signal: string) => { + await cleanupFile(); + process.exit(0); + }; + + process.on('SIGINT', () => signalHandler('SIGINT')); + process.on('SIGTERM', () => signalHandler('SIGTERM')); + process.on('SIGUSR1', () => signalHandler('SIGUSR1')); + process.on('SIGUSR2', () => signalHandler('SIGUSR2')); + + process.on('exit', async () => { + await cleanupFile(); + }); + + process.on('uncaughtException', async (error) => { + console.error('Uncaught Exception:', error); + await cleanupFile(); + process.exit(1); + }); + + process.on('unhandledRejection', async (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + await cleanupFile(); + }); + }); + } + + // 返回手动清理函数,以便需要时主动调用 + return cleanupFile; +}; diff --git a/src/auto/listen/run-check.ts b/src/auto/listen/run-check.ts new file mode 100644 index 0000000..21255f3 --- /dev/null +++ b/src/auto/listen/run-check.ts @@ -0,0 +1,51 @@ +import { getRuntime } from '../runtime.ts'; + +export const getPid = async () => { + const runtime = getRuntime(); + + let pid = 0; + if (runtime.isDeno) { + // @ts-ignore + pid = Deno.pid; + } else { + pid = process.pid; + } + return pid; +}; +export const writeAppid = async (pidPath = './app.pid') => { + const fs = await import('node:fs'); + const pid = await getPid(); + fs.writeFileSync(pidPath, pid + ''); +}; + +export const getPidFromFileAndStop = async () => { + const fs = await import('node:fs'); + if (fs.existsSync('./app.pid')) { + const pid = parseInt(fs.readFileSync('./app.pid', 'utf-8'), 10); + if (!isNaN(pid)) { + if (pid === 0) { + return; + } + try { + process.kill(pid); + console.log(`Stopped process with PID ${pid}`); + } catch (error) { + console.error(`Failed to stop process with PID ${pid}:`); + } + } + } +}; + +export const runFirstCheck = async (path: string, pidPath: string) => { + await getPidFromFileAndStop(); + await writeAppid(pidPath); + try { + const fs = await import('node:fs'); + if (fs.existsSync(path)) { + fs.unlinkSync(path); + console.log(`Socket file ${path} cleaned up during first check`); + } + } catch (error) { + console.error(`Failed to clean up socket file ${path} during first check:`, error); + } +}; diff --git a/src/auto/listen/server-time.ts b/src/auto/listen/server-time.ts new file mode 100644 index 0000000..b16223c --- /dev/null +++ b/src/auto/listen/server-time.ts @@ -0,0 +1,33 @@ +export class ServerTimer { + updatedAt: number; + timer: any; + timeout: number; + onTimeout: any; + interval = 10 * 1000; + constructor(opts?: { timeout?: number }) { + this.timeout = opts?.timeout || 15 * 60 * 1000; + this.run(); + } + startTimer() { + const that = this; + if (this.timer) { + clearInterval(this.timer); + } + this.timer = setInterval(() => { + const updatedAt = Date.now(); + const timeout = that.timeout; + const onTimeout = that.onTimeout; + const isExpired = updatedAt - that.updatedAt > timeout; + if (isExpired) { + onTimeout?.(); + clearInterval(that.timer); + that.timer = null; + } + }, that.interval); + } + + run(): number { + this.updatedAt = Date.now(); + return this.updatedAt; + } +} diff --git a/src/auto/load-ts.ts b/src/auto/load-ts.ts new file mode 100644 index 0000000..71cf1b9 --- /dev/null +++ b/src/auto/load-ts.ts @@ -0,0 +1,38 @@ +import { getRuntime } from './runtime.ts'; +import { glob } from './utils/glob.ts'; +type GlobOptions = { + cwd?: string; + load?: (args?: any) => Promise; +}; + +export const getMatchFiles = async (match: string = './*.ts', { cwd = process.cwd() }: GlobOptions = {}): Promise => { + const runtime = getRuntime(); + if (runtime.isNode) { + console.error(`Node.js is not supported`); + return []; + } + if (runtime.isDeno) { + // Deno 环境下 + return await glob(match); + } + if (runtime.isBun) { + // Bun 环境下 + // @ts-ignore + const { Glob } = await import('bun'); + const path = await import('path'); + // @ts-ignore + const glob = new Glob(match, { cwd, absolute: true, onlyFiles: true }); + const files: string[] = []; + for await (const file of glob.scan('.')) { + files.push(path.join(cwd, file)); + } + // @ts-ignore + return Array.from(files); + } + return []; +}; + +export const loadTS = async (match: string = './*.ts', { cwd = process.cwd(), load }: GlobOptions = {}): Promise => { + const files = await getMatchFiles(match, { cwd }); + return Promise.all(files.map((file) => (load ? load(file) : import(file)))); +}; diff --git a/src/auto/runtime.ts b/src/auto/runtime.ts new file mode 100644 index 0000000..24d85b7 --- /dev/null +++ b/src/auto/runtime.ts @@ -0,0 +1,19 @@ +type RuntimeEngine = 'node' | 'deno' | 'bun'; + +type Runtime = { + isNode?: boolean; + isDeno?: boolean; + isBun?: boolean; + engine: RuntimeEngine; +}; +export const getRuntime = (): Runtime => { + // @ts-ignore + if (typeof Deno !== 'undefined') { + return { isDeno: true, engine: 'deno' }; + } + // @ts-ignore + if (typeof Bun !== 'undefined') { + return { isBun: true, engine: 'bun' }; + } + return { isNode: true, engine: 'node' }; +}; diff --git a/src/auto/utils/glob.ts b/src/auto/utils/glob.ts new file mode 100644 index 0000000..9c04075 --- /dev/null +++ b/src/auto/utils/glob.ts @@ -0,0 +1,83 @@ +type GlobOptions = { + cwd?: string; +}; + +export const glob = async (match: string = './*.ts', { cwd = process.cwd() }: GlobOptions = {}) => { + const fs = await import('node:fs'); + const path = await import('node:path'); + + // 将 glob 模式转换为正则表达式 + const globToRegex = (pattern: string): RegExp => { + const escaped = pattern + .replace(/\./g, '\\.') + .replace(/\*\*/g, '__DOUBLE_STAR__') // 临时替换 ** + .replace(/\*/g, '[^/]*') // * 匹配除 / 外的任意字符 + .replace(/__DOUBLE_STAR__/g, '.*') // ** 匹配任意字符包括 / + .replace(/\?/g, '[^/]'); // ? 匹配除 / 外的单个字符 + return new RegExp(`^${escaped}$`); + }; + + // 递归读取目录 + const readDirRecursive = async (dir: string): Promise => { + const files: string[] = []; + + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isFile()) { + files.push(fullPath); + } else if (entry.isDirectory()) { + // 递归搜索子目录 + const subFiles = await readDirRecursive(fullPath); + files.push(...subFiles); + } + } + } catch (error) { + // 忽略无法访问的目录 + } + + return files; + }; + + // 解析模式是否包含递归搜索 + const hasRecursive = match.includes('**'); + + try { + let allFiles: string[] = []; + + if (hasRecursive) { + // 处理递归模式 + const basePath = match.split('**')[0]; + const startDir = path.resolve(cwd, basePath || '.'); + allFiles = await readDirRecursive(startDir); + } else { + // 处理非递归模式 + const dir = path.resolve(cwd, path.dirname(match)); + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile()) { + allFiles.push(path.join(dir, entry.name)); + } + } + } + + // 创建相对于 cwd 的匹配模式 + const normalizedMatch = path.resolve(cwd, match); + const regex = globToRegex(normalizedMatch); + + // 过滤匹配的文件 + const matchedFiles = allFiles.filter(file => { + const normalizedFile = path.resolve(file); + return regex.test(normalizedFile); + }); + + return matchedFiles; + } catch (error) { + console.error(`Error in glob pattern "${match}":`, error); + return []; + } +}; \ No newline at end of file diff --git a/src/validator/index.ts b/src/validator/index.ts index 9ba1003..fbd9e87 100644 --- a/src/validator/index.ts +++ b/src/validator/index.ts @@ -1,2 +1,6 @@ -export type { Rule, Schema } from './rule.ts'; +import { z } from 'zod'; export { schemaFormRule, createSchema, createSchemaList } from './rule.ts'; + +export type { Rule } from './rule.ts'; + +export type Schema = z.ZodType; diff --git a/src/validator/rule.ts b/src/validator/rule.ts index db5c9e5..626f82b 100644 --- a/src/validator/rule.ts +++ b/src/validator/rule.ts @@ -1,5 +1,4 @@ -import { z, ZodError, Schema } from 'zod'; -export { Schema }; +import { z, ZodError } from 'zod'; type BaseRule = { value?: any; required?: boolean; @@ -64,7 +63,7 @@ export const schemaFormRule = (rule: Rule): z.ZodType => { throw new Error(`Unknown rule type: ${(rule as any)?.type}`); } }; -export const createSchema = (rule: Rule): Schema => { +export const createSchema = (rule: Rule): z.ZodType => { try { rule.required = rule.required ?? false; if (!rule.required) {