From 878b54a0fd2123cfb75c74257a0287900998a219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Wed, 8 Apr 2026 20:31:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8A=E4=BC=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E3=80=81=E7=A7=BB=E9=99=A4=20tjs=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E6=94=AF=E6=8C=81=E3=80=81=E6=B7=BB=E5=8A=A0=20vite-p?= =?UTF-8?q?lugin-pilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 HTTP header 大小写不敏感匹配,解决浏览器上传请求体为空的问题 - 重写 createStreamAfterTarget,修复大文件上传时 body stream 数据流断裂 - 添加 chunked transfer encoding 解码支持 - 读取完 body 后 cancel stream,防止后台循环抛异常炸进程 - 修复 parseHttpRequest 中 split(":") 对含冒号 header value 的错误拆分 - 临时上传同名文件直接覆盖,不再加时间戳前缀 - 移除 tjs (txiki.js) 运行时支持及相关代码 - 安装并配置 vite-plugin-pilot 浏览器测试工具 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +- CLAUDE.md | 7 ++ README.md | 3 +- backend/app.ts | 6 +- backend/server/server.ts | 242 +++++++++++++++++++++++++-------------- backend/server/tjs.ts | 37 ------ backend/upload.ts | 41 +++---- package.json | 1 + pnpm-lock.yaml | 23 ++++ vite.config.ts | 3 +- 10 files changed, 207 insertions(+), 159 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 backend/server/tjs.ts diff --git a/.gitignore b/.gitignore index bac73e0..bf11c31 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ tjs app *.tar -dist_backend \ No newline at end of file +dist_backend +.pilot \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3a498ee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +pnpm dev 可以同时启动前后端,并且都会修改代码后自动重载 + +## 浏览器测试(vite-plugin-pilot) +已安装。`npx pilot run '代码'` 执行 JS(返回结果+日志+快照)、`npx pilot page` 页面状态 +`npx pilot help` 查看pilot所有功能 +常用:`__pilot_clickByText("文本")` 点击、`__pilot_typeByPlaceholder("提示文字", "值")` 输入、`__pilot_waitFor("文本")` 等待、`__pilot_findByText("文本")` 查找。snapshot 中 `#N` 是元素索引。 +多 tab 时用 `npx pilot status` 查看实例列表,`npx pilot run '代码' instance:前缀` 指定目标实例(支持 ID 前缀模糊匹配)。 diff --git a/README.md b/README.md index 1e1ed8b..26f035d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ui 需要展现一些特定的字体,但直接引入字体包又过大,于 ## 安装与使用 -### 使用 node / tjs / llrt 等运行时 +### 使用 node / llrt 等运行时 拉取项目,并将字体文件放到项目内的 font 目录下,然后运行: @@ -34,7 +34,6 @@ pnpm install && pnpm build && pnpm build_backend node ./dist_backend/app.cjs llrt ./dist_backend/app.cjs -tjs run ./dist_backend/app.cjs ### 使用 docker 安装 diff --git a/backend/app.ts b/backend/app.ts index f48f381..1b5a061 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -12,12 +12,10 @@ import { enableTempUpload, adminApiKey, fontDirs } from "./config"; import { parseMultipart } from "./multipart"; import { handleTempUpload, handleAdminUpload } from "./upload"; -let release_name = global.tjs ? "tjs" : globalThis?.process?.release?.name; +let release_name = globalThis?.process?.release?.name; let runtimeReady: Promise; -if (release_name === "tjs") { - runtimeReady = import("./server/tjs").then(() => {}); -} else if (release_name === "node" || release_name === "llrt") { +if (release_name === "node" || release_name === "llrt") { runtimeReady = import("./server/node").then(() => {}); } else { runtimeReady = Promise.resolve(); diff --git a/backend/server/server.ts b/backend/server/server.ts index 0370b17..7cc9008 100644 --- a/backend/server/server.ts +++ b/backend/server/server.ts @@ -27,13 +27,9 @@ export class SimpleHttpServer { private router: cRouter = new cRouter(); constructor(options: { port: number; hostname?: string }) { - let release_name = global.tjs ? "tjs" : globalThis?.process?.release?.name; + const release_name = globalThis?.process?.release?.name; console.log("[release.name]", release_name); - if (global.tjs) { - this.tjsServer(options); - return this; - } - if (release_name === "llrt" || release_name == "node") { + if (release_name === "llrt" || release_name === "node") { import("./tcp_server").then((m) => { const server = m.createTcpServer((socket) => { connectionHandle(socket, (req, res) => this.router.handle(req, res)); @@ -44,18 +40,6 @@ export class SimpleHttpServer { }); } } - private async tjsServer(options: { port: number; hostname?: string }) { - const listener = (await global.tjs.listen( - "tcp", - options.hostname ?? "::", - options.port, - {}, - )) as tjs.Listener; - console.log(`Server is listening on port ${options.port}`); - for await (const connection of listener) { - connectionHandle(connection, this.router.handle.bind(this.router)); - } - } use(...middlewares: cMiddleware[]) { middlewares.forEach((middleware) => this.router.use(middleware)); return this; @@ -63,6 +47,18 @@ export class SimpleHttpServer { } const decoder = new TextDecoder("utf-8"); const encoder = new TextEncoder(); + +/** 合并多个 Uint8Array 为单个 ArrayBuffer */ +function mergeChunks(chunks: Uint8Array[], totalLength: number): ArrayBuffer { + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + return merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength); +} + // 请求头终止符 const target = encoder.encode("\r\n\r\n"); async function connectionHandle( @@ -81,11 +77,20 @@ async function connectionHandle( const httpHeaderText = decoder.decode(header); const httpHeader = parseHttpRequest(httpHeaderText); const hasBody = httpHeader.method !== "GET" && httpHeader.method !== "HEAD"; - /** 根据 Content-Length 读取指定长度的 body,避免在 keep-alive 连接上无限等待 */ + /** 大小写不敏感查找 header */ + const getHeader = (name: string) => { + const lower = name.toLowerCase(); + for (const key of Object.keys(httpHeader.headers)) { + if (key.toLowerCase() === lower) return httpHeader.headers[key]; + } + return undefined; + }; + /** 读取请求体 */ let bodyArrayBuffer: ArrayBuffer | undefined; if (hasBody && body) { - const contentLength = parseInt(httpHeader.headers["Content-Length"] ?? "0", 10); + const contentLength = parseInt(getHeader("Content-Length") ?? "0", 10); if (contentLength > 0) { + /** 根据 Content-Length 读取指定长度的 body */ const chunks: Uint8Array[] = []; let received = 0; for await (const chunk of body) { @@ -93,18 +98,77 @@ async function connectionHandle( received += chunk.length; if (received >= contentLength) break; } - const merged = new Uint8Array(received); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; + body.cancel?.(); + bodyArrayBuffer = mergeChunks(chunks, received); + } else if (getHeader("Transfer-Encoding") === "chunked") { + /** 解码 chunked transfer encoding */ + const chunks: Uint8Array[] = []; + let totalLength = 0; + const chunkBuf: number[] = []; + let state: "size" | "data" | "crlf_after_data" = "size"; + let chunkSize = 0; + let dataRead = 0; + let goto_done = false; + for await (const rawChunk of body) { + for (const byte of rawChunk) { + switch (state) { + case "size": { + if (byte === 13) continue; // \r + if (byte === 10) { + // \n — size 行结束 + const sizeStr = new TextDecoder().decode(new Uint8Array(chunkBuf)).trim(); + chunkSize = parseInt(sizeStr, 16); + chunkBuf.length = 0; + if (chunkSize === 0) { + state = "crlf_after_data"; // 最后一个空行 + } else { + state = "data"; + dataRead = 0; + } + } else { + chunkBuf.push(byte); + } + break; + } + case "data": { + chunkBuf.push(byte); + dataRead++; + if (dataRead >= chunkSize) { + const data = new Uint8Array(chunkBuf); + chunks.push(data); + totalLength += data.length; + chunkBuf.length = 0; + state = "crlf_after_data"; + } + break; + } + case "crlf_after_data": { + // 跳过 trailing \r\n + if (byte === 10) { + if (chunkSize === 0) { + // 结束标记后的 \n + state = "size"; + goto_done = true; + } else { + state = "size"; + } + } + break; + } + } + if (goto_done) break; + } + if (goto_done) break; } - bodyArrayBuffer = merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength); - } else if (httpHeader.headers["Transfer-Encoding"] === "chunked") { - /** chunked transfer encoding 暂不支持,跳过 body */ + if (totalLength > 0) { + bodyArrayBuffer = mergeChunks(chunks, totalLength); + } + body.cancel?.(); + } else { + /** 无 Content-Length 且非 chunked,暂不处理 */ } } - const rawReq = new Request("http://" + httpHeader.headers["Host"] + httpHeader.url, { + const rawReq = new Request("http://" + (getHeader("Host") ?? "localhost") + httpHeader.url, { method: httpHeader.method, headers: httpHeader.headers, }); @@ -121,22 +185,13 @@ async function connectionHandle( const resHeaertText = `HTTP/1.1 ${res.status} OK\r\n${headerText.join("\r\n")}\r\n\r\n`; await resWriter.write(encoder.encode(resHeaertText)); if (res.body) { - // node 运行时 - // 释放写入器的锁定 + /** node 运行时 */ resWriter.releaseLock(); - // https://github.com/saghul/txiki.js/issues/646 await res.body?.pipeTo(connection.writable); } else { - // @ts-expect-error - if (res._bodyInit) { - // tjs 运行时 - // @ts-expect-error - await resWriter.write(res._bodyInit); - } else { - // llrt 运行时 - const buffer = new Uint8Array(await (await res.blob()).arrayBuffer()); - await resWriter.write(buffer); - } + /** llrt 运行时 */ + const buffer = new Uint8Array(await (await res.blob()).arrayBuffer()); + await resWriter.write(buffer); } if (!resWriter.closed) { await resWriter.close(); @@ -161,11 +216,13 @@ function parseHttpRequest(requestText: string) { const headers: Record = {}; for (let i = 1; i < lines.length; i++) { const line = lines[i]; - if (line === "") break; // 空行表示头部结束 + if (line === "" || line === "\r") break; // 空行表示头部结束 - const [key, ...valueParts] = line.split(":"); - const value = valueParts.join(":").trim(); - headers[key.trim()] = value; + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + headers[key] = value; } return { @@ -175,58 +232,69 @@ function parseHttpRequest(requestText: string) { headers, }; } -async function createStreamAfterTarget( +function createStreamAfterTarget( originalStream: ReadableStream, target: Uint8Array, -) { +): Promise<{ header: Uint8Array | null; body: ReadableStream }> { const reader = originalStream.getReader(); let buffer = new Uint8Array(); - // Function to check if target is found in the buffer - function containsTarget(buffer: Uint8Array, target: Uint8Array): number { - for (let i = 0; i <= buffer.length - target.length; i++) { - if (buffer.slice(i, i + target.length).every((value, index) => value === target[index])) { + function containsTarget(buf: Uint8Array, tgt: Uint8Array): number { + for (let i = 0; i <= buf.length - tgt.length; i++) { + if (buf.slice(i, i + tgt.length).every((value, index) => value === tgt[index])) { return i; } } return -1; } - let controller = null as unknown as ReadableStreamDefaultController; - while (true) { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - break; // Stream ended - } - if (controller) { - controller.enqueue(value); - continue; - } - // Append the new chunk to the buffer - const newBuffer = new Uint8Array(buffer.length + value.length); - newBuffer.set(buffer); - newBuffer.set(value, buffer.length); - buffer = newBuffer; - // Check if the target is found in the buffer - const targetIndex = containsTarget(buffer, target); - if (targetIndex !== -1) { - // Found the target data, return the remaining buffer after the target data - const start = targetIndex + target.length; - const header = buffer.slice(0, start); - const remainingData = buffer.slice(start); - const body = new ReadableStream({ - start(c) { - controller = c; - controller.enqueue(remainingData); - }, + return new Promise((resolve) => { + function pump() { + reader.read().then(({ done, value }) => { + if (done) { + resolve({ header: null, body: new ReadableStream() }); + return; + } + const newBuffer = new Uint8Array(buffer.length + value.length); + newBuffer.set(buffer); + newBuffer.set(value, buffer.length); + buffer = newBuffer; + + const targetIndex = containsTarget(buffer, target); + if (targetIndex === -1) { + pump(); + return; + } + const start = targetIndex + target.length; + const header = buffer.slice(0, start); + const remainingData = buffer.slice(start); + + /** body stream:先写入剩余数据,然后在后台继续从 originalStream 读取 */ + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(c) { + controller = c; + if (remainingData.length > 0) { + controller.enqueue(remainingData); + } + /** 后台继续读取 originalStream 并转发到 body stream */ + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { controller.close(); return; } + controller.enqueue(value); + } + } catch (err) { + /** body stream 被消费方 cancel 时预期会抛错 */ + console.log("[createStreamAfterTarget] body stream closed:", err); + } + })(); + }, + }); + resolve({ header, body }); }); - // Create a new stream from the remaining data - return { - header, - body, - }; } - } - return { header: null, body: new ReadableStream() }; // Return an empty stream if the target is not found + pump(); + }); } diff --git a/backend/server/tjs.ts b/backend/server/tjs.ts deleted file mode 100644 index f9fec76..0000000 --- a/backend/server/tjs.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { implInterface } from "../interface"; - -implInterface({ - async stat(path) { - const r = await tjs.stat(path); - return { - isFile: () => r.isFile, - size: r.size, - }; - }, - readFile(path) { - return tjs.readFile(path); - }, - writeFile(path, data) { - return tjs.writeFile(path, data); - }, - async readdir(path) { - const entries: { isFile: () => boolean; name: string }[] = []; - const dir = await tjs.readDir(path); - for await (const entry of dir) { - entries.push({ - isFile: () => entry.isFile, - name: entry.name, - }); - } - await dir.close(); - return entries; - }, - /** TJS 没有 mkdir,通过写入占位文件来确保目录存在 */ - async mkdir(path) { - const placeholder = path + "/.keep"; - await tjs.writeFile(placeholder, new Uint8Array(0)); - }, - unlink(path) { - return tjs.remove(path); - }, -}); diff --git a/backend/upload.ts b/backend/upload.ts index fdf8012..f2a0ec6 100644 --- a/backend/upload.ts +++ b/backend/upload.ts @@ -14,23 +14,6 @@ function sanitizeFilename(filename: string): string { return filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, ""); } -/** 生成带时间戳前缀的文件名,用于 FIFO 排序 */ -function generateTempFilename(originalName: string): string { - const ts = Date.now().toString(36); - const ext = originalName.includes(".") ? "." + originalName.split(".").pop() : ".ttf"; - const base = originalName.replace(/\.[^.]+$/, ""); - return `${ts}_${base}${ext}`; -} - -/** 获取临时目录中的字体文件列表 */ -async function getTempFiles(): Promise { - const entries = await readdir("font/temp"); - return entries - .filter((e) => e.isFile() && isAllowedFontFile(e.name)) - .map((e) => e.name) - .sort(); -} - /** 确保目录存在,不存在则创建 */ async function ensureDir(dir: string) { const { stat, mkdir } = await import("./interface"); @@ -57,20 +40,24 @@ export async function handleTempUpload(fileData: { data: Uint8Array; filename: s await ensureDir("font/temp"); - const existingFiles = await getTempFiles(); + const filename = sanitizeFilename(fileData.filename); + const filePath = path_join("font/temp", filename); - // FIFO: 超出上限时删除最早的文件 - if (existingFiles.length >= tempMaxFiles) { - const toDelete = existingFiles[0]; - try { - await unlink(path_join("font/temp", toDelete)); - } catch { - // 删除失败不影响上传 + /** 同名文件直接覆盖,否则检查文件数量上限 */ + try { + await (await import("./interface")).stat(filePath); + } catch { + const entries = await readdir("font/temp"); + const count = entries.filter((e) => e.isFile() && isAllowedFontFile(e.name)).length; + if (count >= tempMaxFiles) { + const toDelete = entries.find((e) => e.isFile() && isAllowedFontFile(e.name)); + if (toDelete) { + try { await unlink(path_join("font/temp", toDelete.name)); } catch { /* 删除失败不影响上传 */ } + } } } - const filename = generateTempFilename(sanitizeFilename(fileData.filename)); - await writeFile(path_join("font/temp", filename), fileData.data); + await writeFile(filePath, fileData.data); return { success: true }; } diff --git a/package.json b/package.json index 9f2abb4..7da4ea5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "typescript": "^6.0.2", "undici": "^8.0.2", "vite": "^8.0.7", + "vite-plugin-pilot": "^1.0.19", "vite-plugin-solid": "^2.11.12" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 851fcae..8cb3804 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: vite: specifier: ^8.0.7 version: 8.0.7(@types/node@25.5.2)(esbuild@0.27.7) + vite-plugin-pilot: + specifier: ^1.0.19 + version: 1.0.19(magic-string@0.30.21)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.27.7)) vite-plugin-solid: specifier: ^2.11.12 version: 2.11.12(solid-js@1.9.12)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.27.7)) @@ -995,6 +998,20 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + vite-plugin-pilot@1.0.19: + resolution: {integrity: sha512-8Oa+1P+oP8lV1zemyi2Mzz8I2LRPWEyR7Zi7F8l30yNBrGlYsgJvJePSF73U2l/tUdRaF3V7D4ET3Na68Lqvzg==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + '@vue/compiler-dom': '>=3.3' + magic-string: '>=0.30' + vite: '>=5' + peerDependenciesMeta: + '@vue/compiler-dom': + optional: true + magic-string: + optional: true + vite-plugin-solid@2.11.12: resolution: {integrity: sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==} peerDependencies: @@ -1860,6 +1877,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + vite-plugin-pilot@1.0.19(magic-string@0.30.21)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.27.7)): + dependencies: + vite: 8.0.7(@types/node@25.5.2)(esbuild@0.27.7) + optionalDependencies: + magic-string: 0.30.21 + vite-plugin-solid@2.11.12(solid-js@1.9.12)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.27.7)): dependencies: '@babel/core': 7.29.0 diff --git a/vite.config.ts b/vite.config.ts index 136dfd0..4f6d5cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from "vite"; import solid from "vite-plugin-solid"; +import { pilot } from "vite-plugin-pilot"; export default defineConfig({ - plugins: [solid()], + plugins: [solid(), pilot({ locale: "zh" })], server: { host: "0.0.0.0", proxy: {