mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
normalizeSlashes,
|
||||
} from "@/lib/file-access";
|
||||
import {
|
||||
DOCX_PREVIEW_MAX_BYTES,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
documentPreviewKind,
|
||||
getAudioMime,
|
||||
getDocumentMime,
|
||||
getFileExt,
|
||||
getImageMime,
|
||||
} from "@/lib/file-types";
|
||||
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
|
||||
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
|
||||
import { isApiRequestAllowed } from "@/lib/request-security";
|
||||
import {
|
||||
inspectUploadTargets,
|
||||
parseUploadConflictStrategy,
|
||||
validateUploadFileNames,
|
||||
} from "@/lib/file-upload";
|
||||
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
|
||||
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store", ".git",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
|
||||
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
|
||||
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
|
||||
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
|
||||
// Multipart boundaries and headers are not file bytes, but must be bounded too.
|
||||
const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
||||
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
||||
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
||||
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
||||
html: "html", htm: "html", css: "css", scss: "css", less: "css",
|
||||
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
|
||||
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
||||
sql: "sql", graphql: "graphql", gql: "graphql",
|
||||
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
|
||||
env: "bash", gitignore: "bash", txt: "text",
|
||||
pdf: "pdf", docx: "word",
|
||||
};
|
||||
|
||||
function getLanguage(filePath: string): string {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
// Special full-name matches
|
||||
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (base === ".env" || base.startsWith(".env.")) return "bash";
|
||||
if (base === "makefile" || base === "gnumakefile") return "makefile";
|
||||
const ext = base.split(".").pop() ?? "";
|
||||
return EXT_TO_LANGUAGE[ext] ?? "text";
|
||||
}
|
||||
|
||||
function filePathFromSegments(segments: string[]): string {
|
||||
const joined = segments.join("/");
|
||||
const slashJoined = normalizeSlashes(joined);
|
||||
if (isWindowsAbsolutePath(slashJoined)) return slashJoined;
|
||||
return "/" + joined.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function parseFileRequestType(value: string): FileRequestType | null {
|
||||
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
|
||||
}
|
||||
|
||||
async function getUploadDirectory(segments: string[]): Promise<
|
||||
{ directory: string } | { response: NextResponse }
|
||||
> {
|
||||
const directory = filePathFromSegments(segments);
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(directory, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(directory);
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
// A browsable directory can be a symlink. Resolve both sides before writes
|
||||
// so a symlink inside an allowed root cannot redirect uploads outside it.
|
||||
const realDirectory = fs.realpathSync(directory);
|
||||
const realRoots = new Set<string>();
|
||||
for (const root of allowedRoots) {
|
||||
try {
|
||||
realRoots.add(fs.realpathSync(root));
|
||||
} catch {
|
||||
// Ignore stale session roots that no longer exist.
|
||||
}
|
||||
}
|
||||
if (!isFilePathAllowed(realDirectory, realRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
return { directory: realDirectory };
|
||||
}
|
||||
|
||||
function parseUploadFileNames(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
if (!isApiRequestAllowed(request)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const uploadDirectory = await getUploadDirectory(segments);
|
||||
if ("response" in uploadDirectory) return uploadDirectory.response;
|
||||
const { directory } = uploadDirectory;
|
||||
const type = request.nextUrl.searchParams.get("type") ?? "upload";
|
||||
|
||||
if (type === "upload-check") {
|
||||
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
|
||||
const fileNames = parseUploadFileNames(body?.fileNames);
|
||||
if (!fileNames) {
|
||||
return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 });
|
||||
}
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(inspectUploadTargets(directory, fileNames));
|
||||
}
|
||||
|
||||
if (type !== "upload") {
|
||||
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict"));
|
||||
if (!strategy) {
|
||||
return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string");
|
||||
if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) {
|
||||
return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 });
|
||||
}
|
||||
if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
const fileNames = files.map((file) => file.name);
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
const inspection = inspectUploadTargets(directory, fileNames);
|
||||
if (strategy === "error" && inspection.conflicts.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "One or more files already exist",
|
||||
conflicts: inspection.conflicts,
|
||||
nonReplaceable: inspection.nonReplaceable,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const conflictSet = new Set(inspection.conflicts);
|
||||
const nonReplaceableSet = new Set(inspection.nonReplaceable);
|
||||
const uploaded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const destination = path.join(directory, file.name);
|
||||
if (conflictSet.has(file.name) && strategy === "skip") {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) {
|
||||
errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = Buffer.from(await file.arrayBuffer());
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (conflictSet.has(file.name)) {
|
||||
try {
|
||||
fs.unlinkSync(destination);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(destination, bytes, { flag: "wx" });
|
||||
uploaded.push(file.name);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ uploaded, skipped, errors },
|
||||
{ status: errors.length > 0 ? 207 : 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
|
||||
const fileStream = fs.createReadStream(filePath, range);
|
||||
let closed = false;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
fileStream.on("data", (chunk: Buffer) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
fileStream.once("end", () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may cancel media probes before the file stream ends.
|
||||
}
|
||||
});
|
||||
fileStream.once("error", (error) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.error(error);
|
||||
} catch {
|
||||
// The response was already abandoned by the client.
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(filePath: string, asDownload = false): string {
|
||||
const disposition = asDownload ? "attachment" : "inline";
|
||||
const fileName = path.basename(filePath);
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response {
|
||||
const headers = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": getContentDisposition(filePath, asDownload),
|
||||
};
|
||||
|
||||
if (!rangeHeader) {
|
||||
return new Response(createFileBodyStream(filePath), {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(stat.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
||||
if (!match) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let start = match[1] ? Number(match[1]) : 0;
|
||||
let end = match[2] ? Number(match[2]) : stat.size - 1;
|
||||
if (!match[1] && match[2]) {
|
||||
const suffixLength = Number(match[2]);
|
||||
start = Math.max(stat.size - suffixLength, 0);
|
||||
end = stat.size - 1;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, stat.size - 1);
|
||||
const chunkSize = end - start + 1;
|
||||
return new Response(createFileBodyStream(filePath, { start, end }), {
|
||||
status: 206,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
max-width: 840px;
|
||||
min-height: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
padding: 56px 64px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
.file-title {
|
||||
margin: 0 0 28px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #6b7280;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
|
||||
p { margin: 0.65em 0; line-height: 1.7; }
|
||||
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
a { color: #2563eb; }
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 0; background: #fff; }
|
||||
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="file-title">${escapeHtml(fileName)}</div>
|
||||
${bodyHtml}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const filePath = filePathFromSegments(segments);
|
||||
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
|
||||
const type = parseFileRequestType(rawType);
|
||||
if (!type) {
|
||||
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
|
||||
}
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
|
||||
const allowedBySessionReference =
|
||||
!allowedByRoot &&
|
||||
type !== "list" &&
|
||||
await isFilePathReferencedBySession(filePath, sessionId);
|
||||
if (!allowedByRoot && !allowedBySessionReference) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (type === "read") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
if (imageMime) {
|
||||
if (stat.size > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 });
|
||||
}
|
||||
return streamFile(filePath, stat, imageMime, request.headers.get("range"));
|
||||
}
|
||||
const audioMime = getAudioMime(filePath);
|
||||
if (audioMime) {
|
||||
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
|
||||
}
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
if (documentMime) {
|
||||
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
|
||||
}
|
||||
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const language = getLanguage(filePath);
|
||||
return NextResponse.json({ content, language, size: stat.size });
|
||||
}
|
||||
|
||||
if (type === "download") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream";
|
||||
return streamFile(filePath, stat, mime, request.headers.get("range"), true);
|
||||
}
|
||||
|
||||
if (type === "meta") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
const audioMime = getAudioMime(filePath);
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
return NextResponse.json({
|
||||
size: stat.size,
|
||||
language: getLanguage(filePath),
|
||||
mime: imageMime || audioMime || documentMime || "text/plain",
|
||||
previewKind: documentPreviewKind(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "preview") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
if (getFileExt(filePath) !== "docx") {
|
||||
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
|
||||
}
|
||||
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const mammoth = await import("mammoth");
|
||||
const result = await mammoth.convertToHtml(
|
||||
{ path: filePath },
|
||||
{
|
||||
externalFileAccess: false,
|
||||
convertImage: mammoth.images.dataUri,
|
||||
}
|
||||
);
|
||||
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "watch") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let lastMtimeMs = stat.mtimeMs;
|
||||
let lastSize = stat.size;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (eventName: string, data: Record<string, unknown>) => {
|
||||
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
} catch {
|
||||
// client disconnected
|
||||
}
|
||||
};
|
||||
// Send initial ping so client knows connection is live
|
||||
send("connected", { filePath });
|
||||
try {
|
||||
watcher = fs.watch(filePath, () => {
|
||||
try {
|
||||
const s = fs.statSync(filePath);
|
||||
// Some platforms emit watch events for file reads/attribute
|
||||
// access. Ignore those or the client's refresh read loops.
|
||||
if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return;
|
||||
lastMtimeMs = s.mtimeMs;
|
||||
lastSize = s.size;
|
||||
send("change", { mtime: s.mtime.toISOString(), size: s.size });
|
||||
} catch {
|
||||
send("change", { mtime: new Date().toISOString(), size: 0 });
|
||||
}
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
try { controller.close(); } catch { /* ignore */ }
|
||||
});
|
||||
} catch {
|
||||
send("error", { message: "Failed to watch file" });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
try { watcher?.close(); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// type === "list"
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Avoid per-entry stat calls for normal files and directories. Symlinks and
|
||||
// filesystems without directory type information use the stat fallback.
|
||||
const dirents = fs.readdirSync(filePath, { withFileTypes: true });
|
||||
const entries = dirents
|
||||
.filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s)))
|
||||
.flatMap((d) => {
|
||||
const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name));
|
||||
return isDir === null
|
||||
? []
|
||||
: [{ name: d.name, isDir, size: 0, modified: "" }];
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Dirs first, then files, both alphabetically
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries, path: filePath });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user