diff --git a/cli/dev/DevServer.ts b/cli/dev/DevServer.ts index 362e3f6..f8cc950 100644 --- a/cli/dev/DevServer.ts +++ b/cli/dev/DevServer.ts @@ -8,6 +8,7 @@ import path from "node:path" import fs from "node:fs" import type { FileUpdatedEvent } from "lib/file-server/FileServerEvent" import * as chokidar from "chokidar" +import { FilesystemTypesHandler } from "lib/dependency-analysis/FilesystemTypesHandler" export class DevServer { port: number @@ -37,6 +38,8 @@ export class DevServer { */ filesystemWatcher?: chokidar.FSWatcher + private typesHandler?: FilesystemTypesHandler + constructor({ port, componentFilePath, @@ -50,6 +53,7 @@ export class DevServer { this.fsKy = ky.create({ prefixUrl: `http://localhost:${port}`, }) as any + this.typesHandler = new FilesystemTypesHandler(this.projectDir) } async start() { @@ -77,6 +81,8 @@ export class DevServer { ) this.upsertInitialFiles() + + this.typesHandler?.handleInitialTypeDependencies(this.componentFilePath) } async addEntrypoint() { @@ -119,6 +125,8 @@ circuit.add() // because it can be edited by the browser if (relativeFilePath.includes("manual-edits.json")) return + await this.typesHandler?.handleFileTypeDependencies(absoluteFilePath) + console.log(`${relativeFilePath} saved. Applying changes...`) await this.fsKy .post("api/files/upsert", { diff --git a/lib/dependency-analysis/FilesystemTypesHandler.ts b/lib/dependency-analysis/FilesystemTypesHandler.ts new file mode 100644 index 0000000..badf4dc --- /dev/null +++ b/lib/dependency-analysis/FilesystemTypesHandler.ts @@ -0,0 +1,68 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { findImportsInSnippet } from "./findImportsInSnippet" +import { installNodeModuleTypesForSnippet } from "./installNodeModuleTypesForSnippet" + +export class FilesystemTypesHandler { + private projectRoot: string + + constructor(initialDir: string) { + this.projectRoot = this.findProjectRoot(initialDir) + } + + async handleInitialTypeDependencies(filePath: string) { + console.log("Checking initial type dependencies...") + try { + if (!this.areTypesInstalled(filePath)) { + console.log("Installing missing initial types...") + await installNodeModuleTypesForSnippet(filePath) + } + } catch (error) { + console.warn("Error handling initial type dependencies:", error) + } + } + + async handleFileTypeDependencies(filePath: string) { + try { + if (!this.areTypesInstalled(filePath)) { + console.log("Installing missing file types...") + await installNodeModuleTypesForSnippet(filePath) + } + } catch (error) { + console.warn("Failed to verify types:", error) + } + } + + private areTypesInstalled(filePath: string): boolean { + const imports = findImportsInSnippet(filePath) + return imports.every((imp) => this.checkTypeExists(imp)) + } + + private checkTypeExists(importPath: string): boolean { + if (!importPath.startsWith("@tsci/")) return true + + const pathWithoutPrefix = importPath.replace("@tsci/", "") + const [owner, name] = pathWithoutPrefix.split(".") + + const typePath = path.join( + this.projectRoot, + "node_modules", + "@tsci", + `${owner}.${name}`, + "index.d.ts", + ) + + return fs.existsSync(typePath) + } + + private findProjectRoot(startDir: string): string { + let root = path.resolve(startDir) + while (root !== path.parse(root).root) { + if (fs.existsSync(path.join(root, "package.json"))) { + return root + } + root = path.dirname(root) + } + return startDir + } +} diff --git a/lib/dependency-analysis/findImportsInSnippet.ts b/lib/dependency-analysis/findImportsInSnippet.ts new file mode 100644 index 0000000..93779e7 --- /dev/null +++ b/lib/dependency-analysis/findImportsInSnippet.ts @@ -0,0 +1,31 @@ +import * as fs from "node:fs" +import * as ts from "typescript" + +export function findImportsInSnippet(snippetPath: string): string[] { + const content = fs.readFileSync(snippetPath, "utf-8") + const sourceFile = ts.createSourceFile( + snippetPath, + content, + ts.ScriptTarget.Latest, + true, + ) + + const imports: string[] = [] + + function visit(node: ts.Node) { + if (ts.isImportDeclaration(node)) { + const moduleSpecifier = node.moduleSpecifier + if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) { + const importPath = moduleSpecifier.text + if (importPath.startsWith("@tsci/")) { + imports.push(importPath) + } + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return imports +} diff --git a/tests/cli/dev/dev.test.ts b/tests/cli/dev/dev.test.ts new file mode 100644 index 0000000..4d1e745 --- /dev/null +++ b/tests/cli/dev/dev.test.ts @@ -0,0 +1,65 @@ +import { test, expect, afterEach } from "bun:test" +import { DevServer } from "cli/dev/DevServer" +import { getTestFixture } from "tests/fixtures/get-test-fixture" +import fs from "node:fs" +import path from "node:path" + +async function waitForFile( + filePath: string, + timeout: number = 5000, + interval: number = 500, +): Promise { + const endTime = Date.now() + timeout + while (Date.now() < endTime) { + if (fs.existsSync(filePath)) { + return true + } + await new Promise((resolve) => setTimeout(resolve, interval)) + } + return false +} + +test("types are installed and refreshed when files change", async () => { + const { tempDirPath, devServerPort } = await getTestFixture({ + vfs: { + "snippet.tsx": ` + import { useRedLed } from "@tsci/seveibar.red-led" + export const MyCircuit = () => <> + `, + "package.json": "{}", + }, + }) + + const devServer = new DevServer({ + port: devServerPort, + componentFilePath: `${tempDirPath}/snippet.tsx`, + }) + + await devServer.start() + + // Wait for the initial type file to be installed + const typePath = path.join( + tempDirPath, + "node_modules/@tsci/seveibar.red-led/index.d.ts", + ) + const typeFileExists = await waitForFile(typePath) + expect(typeFileExists).toBe(true) + + // Simulate file change with new import + const updatedContent = ` + import { useUsbC } from "@tsci/seveibar.smd-usb-c" + export const MyCircuit = () => <> + ` + fs.writeFileSync(`${tempDirPath}/snippet.tsx`, updatedContent) + + // Wait for the new type file to be installed after update + const typePath2 = path.join( + tempDirPath, + "node_modules/@tsci/seveibar.smd-usb-c/index.d.ts", + ) + const typeFileExists2 = await waitForFile(typePath2) + expect(typeFileExists2).toBe(true) + + // Stop the dev server after the test + await devServer.stop() +}, 10_000)