Browse Source

perf: speed up workflow listing by reading only file headers

Instead of parsing entire workflow JSON files (which can be many MB with
embedded base64 data), read only the first 1KB to extract the name via
regex. Drop nodeCount from the listing. Probe all directories in parallel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
f5332ad150
  1. 94
      src/app/api/list-workflows/route.ts
  2. 7
      src/components/quickstart/WorkflowBrowserView.tsx

94
src/app/api/list-workflows/route.ts

@ -6,10 +6,58 @@ import { validateWorkflowPath } from "@/utils/pathValidation";
interface WorkflowListEntry {
name: string;
directoryPath: string;
nodeCount: number;
lastModified: number;
}
// Read just enough of the file to find the "name" field without parsing the whole thing.
// Workflow JSON starts with { "version": 1, "name": "..." ... } so 1KB is plenty.
const HEAD_BYTES = 1024;
async function probeWorkflow(
dirPath: string,
dirName: string
): Promise<WorkflowListEntry | null> {
try {
const files = await fs.readdir(dirPath);
const jsonFiles = files.filter((f) => f.endsWith(".json"));
for (const jsonFile of jsonFiles) {
const filePath = path.join(dirPath, jsonFile);
try {
const handle = await fs.open(filePath, "r");
try {
const buf = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buf, 0, HEAD_BYTES, 0);
const head = buf.toString("utf-8", 0, bytesRead);
// Quick check: must look like a workflow file
if (!head.includes('"version"') || !head.includes('"nodes"')) {
continue;
}
// Extract name via regex — avoids JSON.parse on potentially huge files
const nameMatch = head.match(/"name"\s*:\s*"([^"]*)"/);
const name = nameMatch ? nameMatch[1] : dirName;
const stat = await fs.stat(filePath);
return {
name,
directoryPath: dirPath,
lastModified: stat.mtimeMs,
};
} finally {
await handle.close();
}
} catch {
continue;
}
}
} catch {
// Can't read directory
}
return null;
}
export async function GET(request: NextRequest) {
const parentPath = request.nextUrl.searchParams.get("path");
@ -32,39 +80,16 @@ export async function GET(request: NextRequest) {
const entries = await fs.readdir(parentPath, { withFileTypes: true });
const directories = entries.filter((e) => e.isDirectory());
const workflows: WorkflowListEntry[] = [];
for (const dir of directories) {
const dirPath = path.join(parentPath, dir.name);
try {
const files = await fs.readdir(dirPath);
const jsonFiles = files.filter((f) => f.endsWith(".json"));
for (const jsonFile of jsonFiles) {
try {
const filePath = path.join(dirPath, jsonFile);
const content = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(content);
// Probe all directories in parallel
const results = await Promise.all(
directories.map((dir) =>
probeWorkflow(path.join(parentPath, dir.name), dir.name)
)
);
if (parsed.version && parsed.nodes && parsed.edges) {
const stat = await fs.stat(filePath);
workflows.push({
name: parsed.name || dir.name,
directoryPath: dirPath,
nodeCount: parsed.nodes.length,
lastModified: stat.mtimeMs,
});
break; // Use first valid workflow file per directory
}
} catch {
continue;
}
}
} catch {
// Skip directories we can't read
continue;
}
}
const workflows = results.filter(
(r): r is WorkflowListEntry => r !== null
);
// Sort by most recently modified first
workflows.sort((a, b) => b.lastModified - a.lastModified);
@ -74,7 +99,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Failed to list workflows",
error:
error instanceof Error ? error.message : "Failed to list workflows",
},
{ status: 500 }
);

7
src/components/quickstart/WorkflowBrowserView.tsx

@ -11,7 +11,6 @@ import {
interface WorkflowListEntry {
name: string;
directoryPath: string;
nodeCount: number;
lastModified: number;
}
@ -261,11 +260,7 @@ export function WorkflowBrowserView({
<span className="text-sm font-medium text-neutral-200 group-hover:text-neutral-100 transition-colors">
{entry.name}
</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-neutral-500">
{entry.nodeCount} node{entry.nodeCount !== 1 ? "s" : ""}
</span>
<span className="text-neutral-700">·</span>
<div className="mt-0.5">
<span className="text-xs text-neutral-500">
{formatRelativeTime(entry.lastModified)}
</span>

Loading…
Cancel
Save