Improve check error performance by off-loading to worker thread w/ incremental compilation (#575)
This commit is contained in:
@@ -22,7 +22,8 @@ const ignore = (file: string) => {
|
|||||||
if (file.startsWith("/scaffold")) {
|
if (file.startsWith("/scaffold")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (file.startsWith("/worker")) {
|
|
||||||
|
if (file.startsWith("/worker") && !file.startsWith("/workers")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (file.startsWith("/node_modules/stacktrace-js")) {
|
if (file.startsWith("/node_modules/stacktrace-js")) {
|
||||||
@@ -121,6 +122,11 @@ const config: ForgeConfig = {
|
|||||||
config: "vite.preload.config.mts",
|
config: "vite.preload.config.mts",
|
||||||
target: "preload",
|
target: "preload",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
entry: "workers/tsc/tsc_worker.ts",
|
||||||
|
config: "vite.worker.config.mts",
|
||||||
|
target: "main",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
renderer: [
|
renderer: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
"package": "npm run clean && electron-forge package",
|
"package": "npm run clean && electron-forge package",
|
||||||
"make": "npm run clean && electron-forge make",
|
"make": "npm run clean && electron-forge make",
|
||||||
"publish": "npm run clean && electron-forge publish",
|
"publish": "npm run clean && electron-forge publish",
|
||||||
"ts": "npx tsc -p tsconfig.app.json --noEmit",
|
"ts": "npm run ts:main && npm run ts:workers",
|
||||||
|
"ts:main": "npx tsc -p tsconfig.app.json --noEmit",
|
||||||
|
"ts:workers": "npx tsc -p workers/tsc/tsconfig.json --noEmit",
|
||||||
"lint": "npx oxlint --fix",
|
"lint": "npx oxlint --fix",
|
||||||
"lint:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously",
|
"lint:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
|||||||
@@ -1,30 +1,13 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDyadWriteTags,
|
SyncFileSystemDelegate,
|
||||||
getDyadRenameTags,
|
SyncVirtualFileSystem,
|
||||||
getDyadDeleteTags,
|
VirtualChanges,
|
||||||
} from "../ipc/processors/response_processor";
|
VirtualFile,
|
||||||
import { normalizePath } from "../ipc/processors/normalizePath";
|
} from "./tsc_types";
|
||||||
|
import { normalizePath } from "./normalizePath";
|
||||||
import log from "electron-log";
|
|
||||||
|
|
||||||
const logger = log.scope("VirtualFileSystem");
|
|
||||||
|
|
||||||
export interface VirtualFile {
|
|
||||||
path: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VirtualRename {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SyncFileSystemDelegate {
|
|
||||||
fileExists?: (fileName: string) => boolean;
|
|
||||||
readFile?: (fileName: string) => string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AsyncFileSystemDelegate {
|
export interface AsyncFileSystemDelegate {
|
||||||
fileExists?: (fileName: string) => Promise<boolean>;
|
fileExists?: (fileName: string) => Promise<boolean>;
|
||||||
@@ -78,11 +61,11 @@ export abstract class BaseVirtualFileSystem {
|
|||||||
/**
|
/**
|
||||||
* Apply changes from a response containing dyad tags
|
* Apply changes from a response containing dyad tags
|
||||||
*/
|
*/
|
||||||
public applyResponseChanges(fullResponse: string): void {
|
public applyResponseChanges({
|
||||||
const writeTags = getDyadWriteTags(fullResponse);
|
deletePaths,
|
||||||
const renameTags = getDyadRenameTags(fullResponse);
|
renameTags,
|
||||||
const deletePaths = getDyadDeleteTags(fullResponse);
|
writeTags,
|
||||||
|
}: VirtualChanges): void {
|
||||||
// Process deletions
|
// Process deletions
|
||||||
for (const deletePath of deletePaths) {
|
for (const deletePath of deletePaths) {
|
||||||
this.deleteFile(deletePath);
|
this.deleteFile(deletePath);
|
||||||
@@ -147,7 +130,7 @@ export abstract class BaseVirtualFileSystem {
|
|||||||
this.virtualFiles.set(toNormalized, content);
|
this.virtualFiles.set(toNormalized, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If we can't read the source file, we'll let the consumer handle it
|
// If we can't read the source file, we'll let the consumer handle it
|
||||||
logger.warn(
|
console.warn(
|
||||||
`Could not read source file for rename: ${fromPath}`,
|
`Could not read source file for rename: ${fromPath}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
@@ -214,7 +197,10 @@ export abstract class BaseVirtualFileSystem {
|
|||||||
/**
|
/**
|
||||||
* Synchronous virtual filesystem
|
* Synchronous virtual filesystem
|
||||||
*/
|
*/
|
||||||
export class SyncVirtualFileSystem extends BaseVirtualFileSystem {
|
export class SyncVirtualFileSystemImpl
|
||||||
|
extends BaseVirtualFileSystem
|
||||||
|
implements SyncVirtualFileSystem
|
||||||
|
{
|
||||||
private delegate: SyncFileSystemDelegate;
|
private delegate: SyncFileSystemDelegate;
|
||||||
|
|
||||||
constructor(baseDir: string, delegate?: SyncFileSystemDelegate) {
|
constructor(baseDir: string, delegate?: SyncFileSystemDelegate) {
|
||||||
52
shared/tsc_types.ts
Normal file
52
shared/tsc_types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export interface SyncVirtualFileSystem {
|
||||||
|
fileExists: (fileName: string) => boolean;
|
||||||
|
readFile: (fileName: string) => string | undefined;
|
||||||
|
|
||||||
|
getVirtualFiles: () => { path: string }[];
|
||||||
|
getDeletedFiles: () => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncFileSystemDelegate {
|
||||||
|
fileExists?: (fileName: string) => boolean;
|
||||||
|
readFile?: (fileName: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Problem {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
message: string;
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemReport {
|
||||||
|
problems: Problem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerInput {
|
||||||
|
appPath: string;
|
||||||
|
virtualChanges: VirtualChanges;
|
||||||
|
tsBuildInfoCacheDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerOutput {
|
||||||
|
success: boolean;
|
||||||
|
data?: ProblemReport;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualChanges {
|
||||||
|
deletePaths: string[];
|
||||||
|
renameTags: VirtualRename[];
|
||||||
|
writeTags: VirtualFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualFile {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualRename {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDyadWriteTags,
|
getDyadWriteTags,
|
||||||
getDyadRenameTags,
|
getDyadRenameTags,
|
||||||
getDyadDeleteTags,
|
|
||||||
processFullResponseActions,
|
|
||||||
getDyadAddDependencyTags,
|
getDyadAddDependencyTags,
|
||||||
} from "../ipc/processors/response_processor";
|
getDyadDeleteTags,
|
||||||
|
} from "../ipc/utils/dyad_tag_parser";
|
||||||
|
|
||||||
|
import { processFullResponseActions } from "../ipc/processors/response_processor";
|
||||||
import {
|
import {
|
||||||
removeDyadTags,
|
removeDyadTags,
|
||||||
hasUnclosedDyadWrite,
|
hasUnclosedDyadWrite,
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ const PreviewHeader = ({
|
|||||||
<DropdownMenuItem onClick={onClearSessionData}>
|
<DropdownMenuItem onClick={onClearSessionData}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>Clear Preview Data</span>
|
<span>Clear Cache</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Clears cookies and local storage for the app preview
|
Clears cookies and local storage and other app cache
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { Worker } from "worker_threads";
|
|||||||
import { createFromTemplate } from "./createFromTemplate";
|
import { createFromTemplate } from "./createFromTemplate";
|
||||||
import { gitCommit } from "../utils/git_utils";
|
import { gitCommit } from "../utils/git_utils";
|
||||||
import { safeSend } from "../utils/safe_sender";
|
import { safeSend } from "../utils/safe_sender";
|
||||||
import { normalizePath } from "../processors/normalizePath";
|
import { normalizePath } from "../../../shared/normalizePath";
|
||||||
|
|
||||||
async function copyDir(
|
async function copyDir(
|
||||||
source: string,
|
source: string,
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import { getDyadAppPath } from "../../paths/paths";
|
|||||||
import { readSettings } from "../../main/settings";
|
import { readSettings } from "../../main/settings";
|
||||||
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
|
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
|
||||||
import { extractCodebase, readFileWithCache } from "../../utils/codebase";
|
import { extractCodebase, readFileWithCache } from "../../utils/codebase";
|
||||||
import {
|
import { processFullResponseActions } from "../processors/response_processor";
|
||||||
getDyadAddDependencyTags,
|
|
||||||
processFullResponseActions,
|
|
||||||
} from "../processors/response_processor";
|
|
||||||
import { streamTestResponse } from "./testing_chat_handlers";
|
import { streamTestResponse } from "./testing_chat_handlers";
|
||||||
import { getTestResponse } from "./testing_chat_handlers";
|
import { getTestResponse } from "./testing_chat_handlers";
|
||||||
import { getModelClient, ModelClient } from "../utils/get_model_client";
|
import { getModelClient, ModelClient } from "../utils/get_model_client";
|
||||||
@@ -51,7 +48,13 @@ import { safeSend } from "../utils/safe_sender";
|
|||||||
import { cleanFullResponse } from "../utils/cleanFullResponse";
|
import { cleanFullResponse } from "../utils/cleanFullResponse";
|
||||||
import { generateProblemReport } from "../processors/tsc";
|
import { generateProblemReport } from "../processors/tsc";
|
||||||
import { createProblemFixPrompt } from "@/shared/problem_prompt";
|
import { createProblemFixPrompt } from "@/shared/problem_prompt";
|
||||||
import { AsyncVirtualFileSystem } from "@/utils/VirtualFilesystem";
|
import { AsyncVirtualFileSystem } from "../../../shared/VirtualFilesystem";
|
||||||
|
import {
|
||||||
|
getDyadAddDependencyTags,
|
||||||
|
getDyadWriteTags,
|
||||||
|
getDyadDeleteTags,
|
||||||
|
getDyadRenameTags,
|
||||||
|
} from "../utils/dyad_tag_parser";
|
||||||
import { fileExists } from "../utils/file_utils";
|
import { fileExists } from "../utils/file_utils";
|
||||||
|
|
||||||
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
||||||
@@ -708,7 +711,14 @@ ${problemReport.problems
|
|||||||
readFile: (fileName: string) => readFileWithCache(fileName),
|
readFile: (fileName: string) => readFileWithCache(fileName),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
virtualFileSystem.applyResponseChanges(fullResponse);
|
const writeTags = getDyadWriteTags(fullResponse);
|
||||||
|
const renameTags = getDyadRenameTags(fullResponse);
|
||||||
|
const deletePaths = getDyadDeleteTags(fullResponse);
|
||||||
|
virtualFileSystem.applyResponseChanges({
|
||||||
|
deletePaths,
|
||||||
|
renameTags,
|
||||||
|
writeTags,
|
||||||
|
});
|
||||||
|
|
||||||
const { formattedOutput: codebaseInfo, files } =
|
const { formattedOutput: codebaseInfo, files } =
|
||||||
await extractCodebase({
|
await extractCodebase({
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import { messages, chats } from "../../db/schema";
|
|||||||
import { desc, eq, and } from "drizzle-orm";
|
import { desc, eq, and } from "drizzle-orm";
|
||||||
import path from "node:path"; // Import path for basename
|
import path from "node:path"; // Import path for basename
|
||||||
// Import tag parsers
|
// Import tag parsers
|
||||||
|
import { processFullResponseActions } from "../processors/response_processor";
|
||||||
import {
|
import {
|
||||||
getDyadAddDependencyTags,
|
getDyadWriteTags,
|
||||||
getDyadChatSummaryTag,
|
getDyadRenameTags,
|
||||||
getDyadDeleteTags,
|
getDyadDeleteTags,
|
||||||
getDyadExecuteSqlTags,
|
getDyadExecuteSqlTags,
|
||||||
getDyadRenameTags,
|
getDyadAddDependencyTags,
|
||||||
getDyadWriteTags,
|
getDyadChatSummaryTag,
|
||||||
getDyadCommandTags,
|
getDyadCommandTags,
|
||||||
processFullResponseActions,
|
} from "../utils/dyad_tag_parser";
|
||||||
} from "../processors/response_processor";
|
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ipcMain, session } from "electron";
|
import { ipcMain, session } from "electron";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { getTypeScriptCachePath } from "@/paths/paths";
|
||||||
|
|
||||||
export const registerSessionHandlers = () => {
|
export const registerSessionHandlers = () => {
|
||||||
ipcMain.handle("clear-session-data", async (_event) => {
|
ipcMain.handle("clear-session-data", async (_event) => {
|
||||||
@@ -8,5 +10,12 @@ export const registerSessionHandlers = () => {
|
|||||||
storages: ["cookies", "localstorage"],
|
storages: ["cookies", "localstorage"],
|
||||||
});
|
});
|
||||||
console.info(`[IPC] All session data cleared for default session`);
|
console.info(`[IPC] All session data cleared for default session`);
|
||||||
|
|
||||||
|
// Clear custom cache data (like tsbuildinfo)
|
||||||
|
try {
|
||||||
|
await fs.rm(getTypeScriptCachePath(), { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Directory might not exist
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ProblemReport, Problem } from "../../shared/tsc_types";
|
||||||
|
export type { ProblemReport, Problem };
|
||||||
|
|
||||||
export interface AppOutput {
|
export interface AppOutput {
|
||||||
type: "stdout" | "stderr" | "info" | "client-error";
|
type: "stdout" | "stderr" | "info" | "client-error";
|
||||||
@@ -246,15 +248,3 @@ export interface AppUpgrade {
|
|||||||
manualUpgradeUrl: string;
|
manualUpgradeUrl: string;
|
||||||
isNeeded: boolean;
|
isNeeded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Problem {
|
|
||||||
file: string;
|
|
||||||
line: number;
|
|
||||||
column: number;
|
|
||||||
message: string;
|
|
||||||
code: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProblemReport {
|
|
||||||
problems: Problem[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,149 +15,21 @@ import {
|
|||||||
executeSupabaseSql,
|
executeSupabaseSql,
|
||||||
} from "../../supabase_admin/supabase_management_client";
|
} from "../../supabase_admin/supabase_management_client";
|
||||||
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
||||||
import { SqlQuery, UserSettings } from "../../lib/schemas";
|
import { UserSettings } from "../../lib/schemas";
|
||||||
import { gitCommit } from "../utils/git_utils";
|
import { gitCommit } from "../utils/git_utils";
|
||||||
import { readSettings } from "@/main/settings";
|
import { readSettings } from "@/main/settings";
|
||||||
import { writeMigrationFile } from "../utils/file_utils";
|
import { writeMigrationFile } from "../utils/file_utils";
|
||||||
import { normalizePath } from "./normalizePath";
|
import {
|
||||||
|
getDyadWriteTags,
|
||||||
|
getDyadRenameTags,
|
||||||
|
getDyadDeleteTags,
|
||||||
|
getDyadAddDependencyTags,
|
||||||
|
getDyadExecuteSqlTags,
|
||||||
|
} from "../utils/dyad_tag_parser";
|
||||||
|
|
||||||
const readFile = fs.promises.readFile;
|
const readFile = fs.promises.readFile;
|
||||||
const logger = log.scope("response_processor");
|
const logger = log.scope("response_processor");
|
||||||
|
|
||||||
export function getDyadWriteTags(fullResponse: string): {
|
|
||||||
path: string;
|
|
||||||
content: string;
|
|
||||||
description?: string;
|
|
||||||
}[] {
|
|
||||||
const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
|
|
||||||
const pathRegex = /path="([^"]+)"/;
|
|
||||||
const descriptionRegex = /description="([^"]+)"/;
|
|
||||||
|
|
||||||
let match;
|
|
||||||
const tags: { path: string; content: string; description?: string }[] = [];
|
|
||||||
|
|
||||||
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
|
|
||||||
const attributesString = match[1];
|
|
||||||
let content = match[2].trim();
|
|
||||||
|
|
||||||
const pathMatch = pathRegex.exec(attributesString);
|
|
||||||
const descriptionMatch = descriptionRegex.exec(attributesString);
|
|
||||||
|
|
||||||
if (pathMatch && pathMatch[1]) {
|
|
||||||
const path = pathMatch[1];
|
|
||||||
const description = descriptionMatch?.[1];
|
|
||||||
|
|
||||||
const contentLines = content.split("\n");
|
|
||||||
if (contentLines[0]?.startsWith("```")) {
|
|
||||||
contentLines.shift();
|
|
||||||
}
|
|
||||||
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
|
|
||||||
contentLines.pop();
|
|
||||||
}
|
|
||||||
content = contentLines.join("\n");
|
|
||||||
|
|
||||||
tags.push({ path: normalizePath(path), content, description });
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
"Found <dyad-write> tag without a valid 'path' attribute:",
|
|
||||||
match[0],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDyadRenameTags(fullResponse: string): {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
}[] {
|
|
||||||
const dyadRenameRegex =
|
|
||||||
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
|
|
||||||
let match;
|
|
||||||
const tags: { from: string; to: string }[] = [];
|
|
||||||
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
|
|
||||||
tags.push({
|
|
||||||
from: normalizePath(match[1]),
|
|
||||||
to: normalizePath(match[2]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDyadDeleteTags(fullResponse: string): string[] {
|
|
||||||
const dyadDeleteRegex =
|
|
||||||
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
|
|
||||||
let match;
|
|
||||||
const paths: string[] = [];
|
|
||||||
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
|
|
||||||
paths.push(normalizePath(match[1]));
|
|
||||||
}
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDyadAddDependencyTags(fullResponse: string): string[] {
|
|
||||||
const dyadAddDependencyRegex =
|
|
||||||
/<dyad-add-dependency packages="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
|
|
||||||
let match;
|
|
||||||
const packages: string[] = [];
|
|
||||||
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
|
|
||||||
packages.push(...match[1].split(" "));
|
|
||||||
}
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDyadChatSummaryTag(fullResponse: string): string | null {
|
|
||||||
const dyadChatSummaryRegex =
|
|
||||||
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
|
|
||||||
const match = dyadChatSummaryRegex.exec(fullResponse);
|
|
||||||
if (match && match[1]) {
|
|
||||||
return match[1].trim();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDyadExecuteSqlTags(fullResponse: string): SqlQuery[] {
|
|
||||||
const dyadExecuteSqlRegex =
|
|
||||||
/<dyad-execute-sql([^>]*)>([\s\S]*?)<\/dyad-execute-sql>/g;
|
|
||||||
const descriptionRegex = /description="([^"]+)"/;
|
|
||||||
let match;
|
|
||||||
const queries: { content: string; description?: string }[] = [];
|
|
||||||
|
|
||||||
while ((match = dyadExecuteSqlRegex.exec(fullResponse)) !== null) {
|
|
||||||
const attributesString = match[1] || "";
|
|
||||||
let content = match[2].trim();
|
|
||||||
const descriptionMatch = descriptionRegex.exec(attributesString);
|
|
||||||
const description = descriptionMatch?.[1];
|
|
||||||
|
|
||||||
// Handle markdown code blocks if present
|
|
||||||
const contentLines = content.split("\n");
|
|
||||||
if (contentLines[0]?.startsWith("```")) {
|
|
||||||
contentLines.shift();
|
|
||||||
}
|
|
||||||
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
|
|
||||||
contentLines.pop();
|
|
||||||
}
|
|
||||||
content = contentLines.join("\n");
|
|
||||||
|
|
||||||
queries.push({ content, description });
|
|
||||||
}
|
|
||||||
|
|
||||||
return queries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDyadCommandTags(fullResponse: string): string[] {
|
|
||||||
const dyadCommandRegex =
|
|
||||||
/<dyad-command type="([^"]+)"[^>]*><\/dyad-command>/g;
|
|
||||||
let match;
|
|
||||||
const commands: string[] = [];
|
|
||||||
|
|
||||||
while ((match = dyadCommandRegex.exec(fullResponse)) !== null) {
|
|
||||||
commands.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Output {
|
interface Output {
|
||||||
message: string;
|
message: string;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
|
||||||
import { ProblemReport } from "../ipc_types";
|
import { ProblemReport } from "../ipc_types";
|
||||||
import { Problem } from "../ipc_types";
|
|
||||||
|
|
||||||
import { normalizePath } from "./normalizePath";
|
|
||||||
import { SyncVirtualFileSystem } from "../../utils/VirtualFilesystem";
|
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
|
import { WorkerInput, WorkerOutput } from "../../../shared/tsc_types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDyadDeleteTags,
|
||||||
|
getDyadRenameTags,
|
||||||
|
getDyadWriteTags,
|
||||||
|
} from "../utils/dyad_tag_parser";
|
||||||
|
import { getTypeScriptCachePath } from "@/paths/paths";
|
||||||
|
|
||||||
const logger = log.scope("tsc");
|
const logger = log.scope("tsc");
|
||||||
|
|
||||||
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
|
|
||||||
try {
|
|
||||||
// Try to load TypeScript from the project's node_modules
|
|
||||||
const requirePath = require.resolve("typescript", { paths: [appPath] });
|
|
||||||
logger.info(`Loading TypeScript from ${requirePath} for app ${appPath}`);
|
|
||||||
const ts = require(requirePath);
|
|
||||||
return ts;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to load TypeScript from ${appPath} because of ${error}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateProblemReport({
|
export async function generateProblemReport({
|
||||||
fullResponse,
|
fullResponse,
|
||||||
appPath,
|
appPath,
|
||||||
@@ -31,181 +21,61 @@ export async function generateProblemReport({
|
|||||||
fullResponse: string;
|
fullResponse: string;
|
||||||
appPath: string;
|
appPath: string;
|
||||||
}): Promise<ProblemReport> {
|
}): Promise<ProblemReport> {
|
||||||
// Load the local TypeScript version from the app's node_modules
|
return new Promise((resolve, reject) => {
|
||||||
const ts = loadLocalTypeScript(appPath);
|
// Determine the worker script path
|
||||||
|
const workerPath = path.join(__dirname, "tsc_worker.js");
|
||||||
|
|
||||||
// Create virtual file system with TypeScript system delegate and apply changes from response
|
logger.info(`Starting TSC worker for app ${appPath}`);
|
||||||
const vfs = new SyncVirtualFileSystem(appPath, {
|
|
||||||
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
|
|
||||||
readFile: (fileName: string) => ts.sys.readFile(fileName),
|
|
||||||
});
|
|
||||||
vfs.applyResponseChanges(fullResponse);
|
|
||||||
|
|
||||||
// Find TypeScript config - throw error if not found
|
// Create the worker
|
||||||
const tsconfigPath = findTypeScriptConfig(appPath);
|
const worker = new Worker(workerPath);
|
||||||
|
|
||||||
// Create TypeScript program with virtual file system
|
// Handle worker messages
|
||||||
const result = await runTypeScriptCheck(ts, appPath, tsconfigPath, vfs);
|
worker.on("message", (output: WorkerOutput) => {
|
||||||
return result;
|
worker.terminate();
|
||||||
}
|
|
||||||
|
|
||||||
function findTypeScriptConfig(appPath: string): string {
|
if (output.success && output.data) {
|
||||||
const possibleConfigs = [
|
logger.info(`TSC worker completed successfully for app ${appPath}`);
|
||||||
// For vite applications, we want to check tsconfig.app.json, since it's the
|
resolve(output.data);
|
||||||
// most important one (client-side app).
|
} else {
|
||||||
// The tsconfig.json in vite apps is a project reference and doesn't
|
logger.error(`TSC worker failed for app ${appPath}: ${output.error}`);
|
||||||
// actually check anything unless you do "--build" which requires a complex
|
reject(new Error(output.error || "Unknown worker error"));
|
||||||
// programmatic approach
|
}
|
||||||
"tsconfig.app.json",
|
|
||||||
// For Next.js applications, it typically has a single tsconfig.json file
|
|
||||||
"tsconfig.json",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const config of possibleConfigs) {
|
|
||||||
const configPath = path.join(appPath, config);
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
return configPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTypeScriptCheck(
|
|
||||||
ts: typeof import("typescript"),
|
|
||||||
appPath: string,
|
|
||||||
tsconfigPath: string,
|
|
||||||
vfs: SyncVirtualFileSystem,
|
|
||||||
): Promise<ProblemReport> {
|
|
||||||
return runSingleProject(ts, appPath, tsconfigPath, vfs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSingleProject(
|
|
||||||
ts: typeof import("typescript"),
|
|
||||||
appPath: string,
|
|
||||||
tsconfigPath: string,
|
|
||||||
vfs: SyncVirtualFileSystem,
|
|
||||||
): Promise<ProblemReport> {
|
|
||||||
// Use the idiomatic way to parse TypeScript config
|
|
||||||
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
|
|
||||||
tsconfigPath,
|
|
||||||
undefined, // No additional options
|
|
||||||
{
|
|
||||||
// Custom system object that can handle our virtual files
|
|
||||||
...ts.sys,
|
|
||||||
fileExists: (fileName: string) => vfs.fileExists(fileName),
|
|
||||||
readFile: (fileName: string) => vfs.readFile(fileName),
|
|
||||||
onUnRecoverableConfigFileDiagnostic: (
|
|
||||||
diagnostic: import("typescript").Diagnostic,
|
|
||||||
) => {
|
|
||||||
throw new Error(
|
|
||||||
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!parsedCommandLine) {
|
|
||||||
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootNames = parsedCommandLine.fileNames;
|
|
||||||
|
|
||||||
// Add any virtual files that aren't already included
|
|
||||||
const virtualTsFiles = vfs
|
|
||||||
.getVirtualFiles()
|
|
||||||
.map((file) => path.resolve(appPath, file.path))
|
|
||||||
.filter(isTypeScriptFile);
|
|
||||||
|
|
||||||
// Remove deleted files from rootNames
|
|
||||||
const deletedFiles = vfs
|
|
||||||
.getDeletedFiles()
|
|
||||||
.map((file) => path.resolve(appPath, file));
|
|
||||||
rootNames = rootNames.filter((fileName) => {
|
|
||||||
const resolvedPath = path.resolve(fileName);
|
|
||||||
return !deletedFiles.includes(resolvedPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const virtualFile of virtualTsFiles) {
|
|
||||||
if (!rootNames.includes(virtualFile)) {
|
|
||||||
rootNames.push(virtualFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create custom compiler host
|
|
||||||
const host = createVirtualCompilerHost(
|
|
||||||
ts,
|
|
||||||
appPath,
|
|
||||||
vfs,
|
|
||||||
parsedCommandLine.options,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create TypeScript program - this is the idiomatic way
|
|
||||||
const program = ts.createProgram(rootNames, parsedCommandLine.options, host);
|
|
||||||
|
|
||||||
// Get diagnostics
|
|
||||||
const diagnostics = [
|
|
||||||
...program.getSyntacticDiagnostics(),
|
|
||||||
...program.getSemanticDiagnostics(),
|
|
||||||
...program.getGlobalDiagnostics(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Convert diagnostics to our format
|
|
||||||
const problems: Problem[] = [];
|
|
||||||
|
|
||||||
for (const diagnostic of diagnostics) {
|
|
||||||
if (!diagnostic.file) continue;
|
|
||||||
|
|
||||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
|
|
||||||
diagnostic.start!,
|
|
||||||
);
|
|
||||||
const message = ts.flattenDiagnosticMessageText(
|
|
||||||
diagnostic.messageText,
|
|
||||||
"\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
problems.push({
|
|
||||||
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
|
|
||||||
line: line + 1, // Convert to 1-based
|
|
||||||
column: character + 1, // Convert to 1-based
|
|
||||||
message,
|
|
||||||
code: diagnostic.code,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
// Handle worker errors
|
||||||
problems,
|
worker.on("error", (error) => {
|
||||||
};
|
logger.error(`TSC worker error for app ${appPath}:`, error);
|
||||||
}
|
worker.terminate();
|
||||||
|
reject(error);
|
||||||
function createVirtualCompilerHost(
|
});
|
||||||
ts: typeof import("typescript"),
|
|
||||||
appPath: string,
|
// Handle worker exit
|
||||||
vfs: SyncVirtualFileSystem,
|
worker.on("exit", (code) => {
|
||||||
compilerOptions: import("typescript").CompilerOptions,
|
if (code !== 0) {
|
||||||
): import("typescript").CompilerHost {
|
logger.error(`TSC worker exited with code ${code} for app ${appPath}`);
|
||||||
const host = ts.createCompilerHost(compilerOptions);
|
reject(new Error(`Worker exited with code ${code}`));
|
||||||
|
}
|
||||||
// Override file reading to use virtual files
|
});
|
||||||
host.readFile = (fileName: string) => {
|
|
||||||
return vfs.readFile(fileName);
|
const writeTags = getDyadWriteTags(fullResponse);
|
||||||
};
|
const renameTags = getDyadRenameTags(fullResponse);
|
||||||
|
const deletePaths = getDyadDeleteTags(fullResponse);
|
||||||
// Override file existence check
|
const virtualChanges = {
|
||||||
host.fileExists = (fileName: string) => {
|
deletePaths,
|
||||||
return vfs.fileExists(fileName);
|
renameTags,
|
||||||
};
|
writeTags,
|
||||||
|
};
|
||||||
return host;
|
|
||||||
}
|
// Send input to worker
|
||||||
|
const input: WorkerInput = {
|
||||||
function isTypeScriptFile(fileName: string): boolean {
|
virtualChanges,
|
||||||
const ext = path.extname(fileName).toLowerCase();
|
appPath,
|
||||||
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
|
tsBuildInfoCacheDir: getTypeScriptCachePath(),
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Sending input to TSC worker for app ${appPath}`);
|
||||||
|
|
||||||
|
worker.postMessage(input);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/ipc/utils/dyad_tag_parser.ts
Normal file
139
src/ipc/utils/dyad_tag_parser.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { normalizePath } from "../../../shared/normalizePath";
|
||||||
|
import log from "electron-log";
|
||||||
|
import { SqlQuery } from "../../lib/schemas";
|
||||||
|
|
||||||
|
const logger = log.scope("dyad_tag_parser");
|
||||||
|
|
||||||
|
export function getDyadWriteTags(fullResponse: string): {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
description?: string;
|
||||||
|
}[] {
|
||||||
|
const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
|
||||||
|
const pathRegex = /path="([^"]+)"/;
|
||||||
|
const descriptionRegex = /description="([^"]+)"/;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
const tags: { path: string; content: string; description?: string }[] = [];
|
||||||
|
|
||||||
|
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
|
||||||
|
const attributesString = match[1];
|
||||||
|
let content = match[2].trim();
|
||||||
|
|
||||||
|
const pathMatch = pathRegex.exec(attributesString);
|
||||||
|
const descriptionMatch = descriptionRegex.exec(attributesString);
|
||||||
|
|
||||||
|
if (pathMatch && pathMatch[1]) {
|
||||||
|
const path = pathMatch[1];
|
||||||
|
const description = descriptionMatch?.[1];
|
||||||
|
|
||||||
|
const contentLines = content.split("\n");
|
||||||
|
if (contentLines[0]?.startsWith("```")) {
|
||||||
|
contentLines.shift();
|
||||||
|
}
|
||||||
|
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
|
||||||
|
contentLines.pop();
|
||||||
|
}
|
||||||
|
content = contentLines.join("\n");
|
||||||
|
|
||||||
|
tags.push({ path: normalizePath(path), content, description });
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Found <dyad-write> tag without a valid 'path' attribute:",
|
||||||
|
match[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDyadRenameTags(fullResponse: string): {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}[] {
|
||||||
|
const dyadRenameRegex =
|
||||||
|
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
|
||||||
|
let match;
|
||||||
|
const tags: { from: string; to: string }[] = [];
|
||||||
|
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
|
||||||
|
tags.push({
|
||||||
|
from: normalizePath(match[1]),
|
||||||
|
to: normalizePath(match[2]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDyadDeleteTags(fullResponse: string): string[] {
|
||||||
|
const dyadDeleteRegex =
|
||||||
|
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
|
||||||
|
let match;
|
||||||
|
const paths: string[] = [];
|
||||||
|
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
|
||||||
|
paths.push(normalizePath(match[1]));
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDyadAddDependencyTags(fullResponse: string): string[] {
|
||||||
|
const dyadAddDependencyRegex =
|
||||||
|
/<dyad-add-dependency packages="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
|
||||||
|
let match;
|
||||||
|
const packages: string[] = [];
|
||||||
|
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
|
||||||
|
packages.push(...match[1].split(" "));
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDyadChatSummaryTag(fullResponse: string): string | null {
|
||||||
|
const dyadChatSummaryRegex =
|
||||||
|
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
|
||||||
|
const match = dyadChatSummaryRegex.exec(fullResponse);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDyadExecuteSqlTags(fullResponse: string): SqlQuery[] {
|
||||||
|
const dyadExecuteSqlRegex =
|
||||||
|
/<dyad-execute-sql([^>]*)>([\s\S]*?)<\/dyad-execute-sql>/g;
|
||||||
|
const descriptionRegex = /description="([^"]+)"/;
|
||||||
|
let match;
|
||||||
|
const queries: { content: string; description?: string }[] = [];
|
||||||
|
|
||||||
|
while ((match = dyadExecuteSqlRegex.exec(fullResponse)) !== null) {
|
||||||
|
const attributesString = match[1] || "";
|
||||||
|
let content = match[2].trim();
|
||||||
|
const descriptionMatch = descriptionRegex.exec(attributesString);
|
||||||
|
const description = descriptionMatch?.[1];
|
||||||
|
|
||||||
|
// Handle markdown code blocks if present
|
||||||
|
const contentLines = content.split("\n");
|
||||||
|
if (contentLines[0]?.startsWith("```")) {
|
||||||
|
contentLines.shift();
|
||||||
|
}
|
||||||
|
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
|
||||||
|
contentLines.pop();
|
||||||
|
}
|
||||||
|
content = contentLines.join("\n");
|
||||||
|
|
||||||
|
queries.push({ content, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDyadCommandTags(fullResponse: string): string[] {
|
||||||
|
const dyadCommandRegex =
|
||||||
|
/<dyad-command type="([^"]+)"[^>]*><\/dyad-command>/g;
|
||||||
|
let match;
|
||||||
|
const commands: string[] = [];
|
||||||
|
|
||||||
|
while ((match = dyadCommandRegex.exec(fullResponse)) !== null) {
|
||||||
|
commands.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
@@ -193,11 +193,6 @@ export interface FileChange {
|
|||||||
isServerFunction: boolean;
|
isServerFunction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SqlQuery {
|
|
||||||
content: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodeProposal {
|
export interface CodeProposal {
|
||||||
type: "code-proposal";
|
type: "code-proposal";
|
||||||
title: string;
|
title: string;
|
||||||
@@ -268,3 +263,8 @@ export interface ProposalResult {
|
|||||||
chatId: number;
|
chatId: number;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SqlQuery {
|
||||||
|
content: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export function getDyadAppPath(appPath: string): string {
|
|||||||
return path.join(os.homedir(), "dyad-apps", appPath);
|
return path.join(os.homedir(), "dyad-apps", appPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTypeScriptCachePath(): string {
|
||||||
|
const electron = getElectron();
|
||||||
|
return path.join(electron!.app.getPath("sessionData"), "typescript-cache");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the user data path, handling both Electron and non-Electron environments
|
* Gets the user data path, handling both Electron and non-Electron environments
|
||||||
* In Electron: returns the app's userData directory
|
* In Electron: returns the app's userData directory
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
|||||||
import { glob } from "glob";
|
import { glob } from "glob";
|
||||||
import { AppChatContext } from "../lib/schemas";
|
import { AppChatContext } from "../lib/schemas";
|
||||||
import { readSettings } from "@/main/settings";
|
import { readSettings } from "@/main/settings";
|
||||||
import { AsyncVirtualFileSystem } from "./VirtualFilesystem";
|
import { AsyncVirtualFileSystem } from "../../shared/VirtualFilesystem";
|
||||||
|
|
||||||
const logger = log.scope("utils/codebase");
|
const logger = log.scope("utils/codebase");
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "e2e-tests"],
|
"include": ["src", "e2e-tests", "shared"],
|
||||||
"exclude": ["e2e-tests/fixtures"]
|
"exclude": ["e2e-tests/fixtures"]
|
||||||
}
|
}
|
||||||
|
|||||||
29
vite.worker.config.mts
Normal file
29
vite.worker.config.mts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
// target: "node16",
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, "workers/tsc/tsc_worker.ts"),
|
||||||
|
name: "tsc_worker",
|
||||||
|
fileName: "tsc_worker",
|
||||||
|
formats: ["cjs"],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ["node:fs", "node:path", "node:worker_threads", "typescript"],
|
||||||
|
// output: {
|
||||||
|
// dir: "dist/workers/tsc",
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
// outDir: "dist/workers/tsc",
|
||||||
|
// emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
307
workers/tsc/tsc_worker.ts
Normal file
307
workers/tsc/tsc_worker.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { parentPort } from "node:worker_threads";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Problem,
|
||||||
|
ProblemReport,
|
||||||
|
SyncVirtualFileSystem,
|
||||||
|
WorkerInput,
|
||||||
|
WorkerOutput,
|
||||||
|
} from "../../shared/tsc_types";
|
||||||
|
import { SyncVirtualFileSystemImpl } from "../../shared/VirtualFilesystem";
|
||||||
|
|
||||||
|
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
|
||||||
|
try {
|
||||||
|
// Try to load TypeScript from the project's node_modules
|
||||||
|
const requirePath = require.resolve("typescript", { paths: [appPath] });
|
||||||
|
const ts = require(requirePath);
|
||||||
|
return ts;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load TypeScript from ${appPath} because of ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTypeScriptConfig(appPath: string): string {
|
||||||
|
const possibleConfigs = [
|
||||||
|
// For vite applications, we want to check tsconfig.app.json, since it's the
|
||||||
|
// most important one (client-side app).
|
||||||
|
// The tsconfig.json in vite apps is a project reference and doesn't
|
||||||
|
// actually check anything unless you do "--build" which requires a complex
|
||||||
|
// programmatic approach
|
||||||
|
"tsconfig.app.json",
|
||||||
|
// For Next.js applications, it typically has a single tsconfig.json file
|
||||||
|
"tsconfig.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const config of possibleConfigs) {
|
||||||
|
const configPath = path.join(appPath, config);
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
return configPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTypeScriptCheck(
|
||||||
|
ts: typeof import("typescript"),
|
||||||
|
vfs: SyncVirtualFileSystem,
|
||||||
|
{
|
||||||
|
appPath,
|
||||||
|
tsconfigPath,
|
||||||
|
tsBuildInfoCacheDir,
|
||||||
|
}: {
|
||||||
|
appPath: string;
|
||||||
|
tsconfigPath: string;
|
||||||
|
tsBuildInfoCacheDir: string;
|
||||||
|
},
|
||||||
|
): Promise<ProblemReport> {
|
||||||
|
return runSingleProject(ts, vfs, {
|
||||||
|
appPath,
|
||||||
|
tsconfigPath,
|
||||||
|
tsBuildInfoCacheDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSingleProject(
|
||||||
|
ts: typeof import("typescript"),
|
||||||
|
vfs: SyncVirtualFileSystem,
|
||||||
|
{
|
||||||
|
appPath,
|
||||||
|
tsconfigPath,
|
||||||
|
tsBuildInfoCacheDir,
|
||||||
|
}: {
|
||||||
|
appPath: string;
|
||||||
|
tsconfigPath: string;
|
||||||
|
tsBuildInfoCacheDir: string;
|
||||||
|
},
|
||||||
|
): Promise<ProblemReport> {
|
||||||
|
// Use the idiomatic way to parse TypeScript config
|
||||||
|
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
|
||||||
|
tsconfigPath,
|
||||||
|
undefined, // No additional options
|
||||||
|
{
|
||||||
|
// Custom system object that can handle our virtual files
|
||||||
|
...ts.sys,
|
||||||
|
fileExists: (fileName: string) => vfs.fileExists(fileName),
|
||||||
|
readFile: (fileName: string) => vfs.readFile(fileName),
|
||||||
|
onUnRecoverableConfigFileDiagnostic: (
|
||||||
|
diagnostic: import("typescript").Diagnostic,
|
||||||
|
) => {
|
||||||
|
throw new Error(
|
||||||
|
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!parsedCommandLine) {
|
||||||
|
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable incremental compilation by setting tsBuildInfoFile if not already set
|
||||||
|
const options = { ...parsedCommandLine.options };
|
||||||
|
if (!options.tsBuildInfoFile && options.incremental !== false) {
|
||||||
|
// Place the buildinfo file in a temp directory to avoid polluting the project
|
||||||
|
const tmpDir = tsBuildInfoCacheDir;
|
||||||
|
if (!fs.existsSync(tmpDir)) {
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique filename based on both the app path and tsconfig path to prevent collisions
|
||||||
|
const configName = path.basename(tsconfigPath, path.extname(tsconfigPath));
|
||||||
|
const appHash = Buffer.from(appPath)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/[/+=]/g, "_");
|
||||||
|
options.tsBuildInfoFile = path.join(
|
||||||
|
tmpDir,
|
||||||
|
`${appHash}-${configName}.tsbuildinfo`,
|
||||||
|
);
|
||||||
|
options.incremental = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootNames = parsedCommandLine.fileNames;
|
||||||
|
|
||||||
|
// Add any virtual files that aren't already included
|
||||||
|
const virtualTsFiles = vfs
|
||||||
|
.getVirtualFiles()
|
||||||
|
.map((file) => path.resolve(appPath, file.path))
|
||||||
|
.filter(isTypeScriptFile);
|
||||||
|
|
||||||
|
// Remove deleted files from rootNames
|
||||||
|
const deletedFiles = vfs
|
||||||
|
.getDeletedFiles()
|
||||||
|
.map((file) => path.resolve(appPath, file));
|
||||||
|
rootNames = rootNames.filter((fileName) => {
|
||||||
|
const resolvedPath = path.resolve(fileName);
|
||||||
|
return !deletedFiles.includes(resolvedPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const virtualFile of virtualTsFiles) {
|
||||||
|
if (!rootNames.includes(virtualFile)) {
|
||||||
|
rootNames.push(virtualFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create custom compiler host
|
||||||
|
const host = createVirtualCompilerHost(ts, appPath, vfs, options);
|
||||||
|
|
||||||
|
// Create incremental program - TypeScript will automatically use the tsBuildInfo file
|
||||||
|
const builderProgram = ts.createIncrementalProgram({
|
||||||
|
rootNames,
|
||||||
|
options,
|
||||||
|
host,
|
||||||
|
configFileParsingDiagnostics:
|
||||||
|
ts.getConfigFileParsingDiagnostics(parsedCommandLine),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get diagnostics - the incremental program optimizes this by only checking changed files
|
||||||
|
const diagnostics = [
|
||||||
|
...builderProgram.getSyntacticDiagnostics(),
|
||||||
|
...builderProgram.getSemanticDiagnostics(),
|
||||||
|
...builderProgram.getGlobalDiagnostics(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Emit the build info file to persist the incremental state
|
||||||
|
builderProgram.emit();
|
||||||
|
|
||||||
|
// Convert diagnostics to our format
|
||||||
|
const problems: Problem[] = [];
|
||||||
|
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
if (!diagnostic.file) continue;
|
||||||
|
|
||||||
|
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
|
||||||
|
diagnostic.start!,
|
||||||
|
);
|
||||||
|
const message = ts.flattenDiagnosticMessageText(
|
||||||
|
diagnostic.messageText,
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
problems.push({
|
||||||
|
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
|
||||||
|
line: line + 1, // Convert to 1-based
|
||||||
|
column: character + 1, // Convert to 1-based
|
||||||
|
message,
|
||||||
|
code: diagnostic.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
problems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVirtualCompilerHost(
|
||||||
|
ts: typeof import("typescript"),
|
||||||
|
appPath: string,
|
||||||
|
vfs: SyncVirtualFileSystem,
|
||||||
|
compilerOptions: import("typescript").CompilerOptions,
|
||||||
|
): import("typescript").CompilerHost {
|
||||||
|
const host = ts.createIncrementalCompilerHost(compilerOptions);
|
||||||
|
|
||||||
|
// Override file reading to use virtual files
|
||||||
|
host.readFile = (fileName: string) => {
|
||||||
|
return vfs.readFile(fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override file existence check
|
||||||
|
host.fileExists = (fileName: string) => {
|
||||||
|
return vfs.fileExists(fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override getCurrentDirectory to ensure proper resolution
|
||||||
|
host.getCurrentDirectory = () => appPath;
|
||||||
|
|
||||||
|
// Override writeFile to handle virtual file system
|
||||||
|
// This is important for writing the tsBuildInfo file
|
||||||
|
const originalWriteFile = host.writeFile;
|
||||||
|
host.writeFile = (
|
||||||
|
fileName: string,
|
||||||
|
data: string,
|
||||||
|
writeByteOrderMark?: boolean,
|
||||||
|
onError?: (message: string) => void,
|
||||||
|
) => {
|
||||||
|
// Only write build info files to disk, not emit files
|
||||||
|
if (fileName.endsWith(".tsbuildinfo")) {
|
||||||
|
originalWriteFile?.call(
|
||||||
|
host,
|
||||||
|
fileName,
|
||||||
|
data,
|
||||||
|
!!writeByteOrderMark,
|
||||||
|
onError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ignore other emit files since we're only doing type checking
|
||||||
|
};
|
||||||
|
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypeScriptFile(fileName: string): boolean {
|
||||||
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
|
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processTypeScriptCheck(
|
||||||
|
input: WorkerInput,
|
||||||
|
): Promise<WorkerOutput> {
|
||||||
|
try {
|
||||||
|
const { appPath, virtualChanges, tsBuildInfoCacheDir } = input;
|
||||||
|
|
||||||
|
// Load the local TypeScript version from the app's node_modules
|
||||||
|
const ts = loadLocalTypeScript(appPath);
|
||||||
|
|
||||||
|
const vfs = new SyncVirtualFileSystemImpl(appPath, {
|
||||||
|
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
|
||||||
|
readFile: (fileName: string) => ts.sys.readFile(fileName),
|
||||||
|
});
|
||||||
|
vfs.applyResponseChanges(virtualChanges);
|
||||||
|
|
||||||
|
// Find TypeScript config - throw error if not found
|
||||||
|
const tsconfigPath = findTypeScriptConfig(appPath);
|
||||||
|
|
||||||
|
// Create TypeScript program with virtual file system
|
||||||
|
const result = await runTypeScriptCheck(ts, vfs, {
|
||||||
|
appPath,
|
||||||
|
tsconfigPath,
|
||||||
|
tsBuildInfoCacheDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from main thread
|
||||||
|
parentPort?.on("message", async (input: WorkerInput) => {
|
||||||
|
const output = await processTypeScriptCheck(input);
|
||||||
|
parentPort?.postMessage(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the path to use forward slashes instead of backslashes.
|
||||||
|
* This is important to prevent weird Git issues, particularly on Windows.
|
||||||
|
* @param path Source path.
|
||||||
|
* @returns Normalized path.
|
||||||
|
*/
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
20
workers/tsc/tsconfig.json
Normal file
20
workers/tsc/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "System",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outFile": "./dist/tsc_worker.js",
|
||||||
|
"rootDir": "../../",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": "./"
|
||||||
|
},
|
||||||
|
"include": ["*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user