修复上传功能、移除 tjs 运行时支持、添加 vite-plugin-pilot

- 修复 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 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-08 20:31:43 +08:00
parent 43a1d2cf64
commit 878b54a0fd
10 changed files with 207 additions and 159 deletions

3
.gitignore vendored
View File

@ -29,4 +29,5 @@ tjs
app
*.tar
dist_backend
dist_backend
.pilot

7
CLAUDE.md Normal file
View File

@ -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 前缀模糊匹配)。

View File

@ -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 安装

View File

@ -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<void>;
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();

View File

@ -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<string, string> = {};
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<Uint8Array>,
target: Uint8Array,
) {
): Promise<{ header: Uint8Array | null; body: ReadableStream<Uint8Array> }> {
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<Uint8Array>;
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<Uint8Array>({
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<Uint8Array>() });
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<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
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<Uint8Array>() }; // Return an empty stream if the target is not found
pump();
});
}

View File

@ -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);
},
});

View File

@ -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<string[]> {
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 };
}

View File

@ -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"
}
}

23
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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: {