From 79b92f2107b6e13547cbee4448797afd543c43ca Mon Sep 17 00:00:00 2001 From: techmannih Date: Wed, 3 Jun 2026 03:51:41 +0530 Subject: [PATCH 1/4] Bridge runtime platform config into CLI dev --- lib/dev/DevServer.ts | 43 +++++++- lib/project-config/index.ts | 37 ++++--- lib/server/createHttpServer.ts | 63 ++++++++++- lib/server/runtime-platform-config.ts | 148 ++++++++++++++++++++++++++ lib/site/getIndex.ts | 2 + tests/test4-get-index-token.test.ts | 23 ++++ 6 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 lib/server/runtime-platform-config.ts diff --git a/lib/dev/DevServer.ts b/lib/dev/DevServer.ts index abf08bb48..2586a20a6 100644 --- a/lib/dev/DevServer.ts +++ b/lib/dev/DevServer.ts @@ -5,6 +5,7 @@ import * as chokidar from "chokidar" import Debug from "debug" import kleur from "kleur" import ky, { TimeoutError } from "ky" +import type { PlatformConfig } from "@tscircuit/props" import { setSessionToken } from "lib/cli-config" import { getAllNodeModuleFilePaths } from "lib/dependency-analysis/getNodeModuleDependencies" import type { @@ -12,7 +13,11 @@ import type { FileUpdatedEvent, } from "lib/file-server/FileServerEvent" import type { FileServerRoutes } from "lib/file-server/FileServerRoutes" -import { loadProjectConfig } from "lib/project-config" +import { + findRuntimeProjectConfigModulePath, + loadProjectConfig, + loadRuntimeProjectConfig, +} from "lib/project-config" import { EventsWatcher } from "lib/server/EventsWatcher" import { createHttpServer } from "lib/server/createHttpServer" import { addPackage } from "lib/shared/add-package" @@ -43,6 +48,8 @@ export class DevServer { projectDir: string /** Paths or directory names to ignore when syncing files */ ignoredFiles: string[] + runtimePlatformConfig?: PlatformConfig + runtimeConfigModulePath?: string /** Whether to enable the KiCad PCM proxy server */ kicadPcm: boolean @@ -94,6 +101,27 @@ export class DevServer { } async start() { + const runtimeProjectConfig = await loadRuntimeProjectConfig(this.projectDir) + this.runtimePlatformConfig = runtimeProjectConfig?.platformConfig + this.runtimeConfigModulePath = + findRuntimeProjectConfigModulePath(this.projectDir) ?? undefined + + const runtimeIgnoredFiles = runtimeProjectConfig?.ignoredFiles ?? [] + this.ignoredFiles = [...runtimeIgnoredFiles] + + if (this.runtimeConfigModulePath) { + const relativeConfigPath = path.relative( + this.projectDir, + this.runtimeConfigModulePath, + ) + if ( + relativeConfigPath && + !this.ignoredFiles.includes(relativeConfigPath) + ) { + this.ignoredFiles.push(relativeConfigPath) + } + } + const { server } = await createHttpServer({ port: this.port, defaultMainComponentPath: path.relative( @@ -103,6 +131,7 @@ export class DevServer { kicadPcm: this.kicadPcm, projectDir: this.projectDir, entryFile: this.componentFilePath, + runtimePlatformConfig: this.runtimePlatformConfig, }) this.httpServer = server @@ -367,9 +396,15 @@ export class DevServer { private async uploadInitialNodeModules() { try { console.log(kleur.blue("Analyzing node_modules dependencies...")) - const nodeModuleFiles = getAllNodeModuleFilePaths( - this.componentFilePath, - this.projectDir, + const nodeModuleFiles = Array.from( + new Set( + [this.componentFilePath, this.runtimeConfigModulePath].flatMap( + (filePath) => + filePath + ? getAllNodeModuleFilePaths(filePath, this.projectDir) + : [], + ), + ), ) console.log( diff --git a/lib/project-config/index.ts b/lib/project-config/index.ts index cf90c85c5..89f368b14 100644 --- a/lib/project-config/index.ts +++ b/lib/project-config/index.ts @@ -123,21 +123,32 @@ const loadProjectConfigModule = async ( ): Promise => { loadProjectEnv(projectDir) + const configPath = findRuntimeProjectConfigModulePath(projectDir) + if (!configPath) { + return null + } + + try { + const moduleUrl = pathToFileURL(configPath) + const stat = fs.statSync(configPath) + moduleUrl.searchParams.set("tsci", String(stat.mtimeMs)) + const importedModule = await import(moduleUrl.href) + const exportedConfig = + importedModule.default ?? importedModule.config ?? importedModule + return parseProjectConfigObject(exportedConfig) + } catch (error) { + console.error(`Error loading ${path.basename(configPath)}: ${error}`) + return null + } +} + +export const findRuntimeProjectConfigModulePath = ( + projectDir: string = process.cwd(), +): string | null => { for (const configFileName of CONFIG_MODULE_FILENAMES) { const configPath = path.join(projectDir, configFileName) - if (!fs.existsSync(configPath)) continue - - try { - const moduleUrl = pathToFileURL(configPath) - const stat = fs.statSync(configPath) - moduleUrl.searchParams.set("tsci", String(stat.mtimeMs)) - const importedModule = await import(moduleUrl.href) - const exportedConfig = - importedModule.default ?? importedModule.config ?? importedModule - return parseProjectConfigObject(exportedConfig) - } catch (error) { - console.error(`Error loading ${configFileName}: ${error}`) - return null + if (fs.existsSync(configPath)) { + return configPath } } diff --git a/lib/server/createHttpServer.ts b/lib/server/createHttpServer.ts index 8b88cec2d..d615bfaad 100644 --- a/lib/server/createHttpServer.ts +++ b/lib/server/createHttpServer.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs" import * as http from "node:http" +import type { PlatformConfig } from "@tscircuit/props" // @ts-ignore import runFrameStandaloneBundleContent from "@tscircuit/runframe/standalone" with { type: "text", @@ -11,6 +12,11 @@ import pkg from "../../package.json" import winterspecBundle from "@tscircuit/file-server/dist/bundle.js" import { getIndex } from "../site/getIndex" import { createKicadPcmProxy } from "./kicad-pcm-proxy" +import { + createRuntimePlatformConfigClientScript, + invokeRuntimePlatformConfigFunction, + patchStandaloneForRuntimePlatformConfig, +} from "./runtime-platform-config" export const createHttpServer = async ({ port = 3020, @@ -18,12 +24,14 @@ export const createHttpServer = async ({ kicadPcm, projectDir, entryFile, + runtimePlatformConfig, }: { port?: number defaultMainComponentPath?: string kicadPcm?: boolean projectDir?: string entryFile?: string + runtimePlatformConfig?: PlatformConfig }) => { const fileServerHandler = getNodeHandler(winterspecBundle as any, {}) @@ -106,6 +114,52 @@ export const createHttpServer = async ({ } } + if ( + url.pathname === "/__tsci/runtime-platform-config/call" && + req.method === "POST" + ) { + try { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + path?: string[] + args?: unknown[] + } + + if (!Array.isArray(body.path)) { + res.writeHead(400, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ error: "Invalid runtime platform config path" }), + ) + return + } + + const result = await invokeRuntimePlatformConfigFunction( + runtimePlatformConfig, + body.path, + Array.isArray(body.args) ? body.args : [], + ) + + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ result })) + return + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : String(error ?? "Unknown error"), + }), + ) + return + } + } + if (url.pathname === "/standalone.min.js") { const standaloneFilePath = process.env.RUNFRAME_STANDALONE_FILE_PATH @@ -113,7 +167,11 @@ export const createHttpServer = async ({ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", }) - res.end(runFrameStandaloneBundleContent) + res.end( + patchStandaloneForRuntimePlatformConfig( + runFrameStandaloneBundleContent, + ), + ) return } @@ -122,7 +180,7 @@ export const createHttpServer = async ({ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", }) - res.end(content) + res.end(patchStandaloneForRuntimePlatformConfig(content)) return } catch (error) { console.info( @@ -142,6 +200,7 @@ export const createHttpServer = async ({ const html = await getIndex( defaultMainComponentPath, fileServerApiBaseUrl, + createRuntimePlatformConfigClientScript(runtimePlatformConfig), ) res.writeHead(200, { "Content-Type": "text/html" }) res.end(html) diff --git a/lib/server/runtime-platform-config.ts b/lib/server/runtime-platform-config.ts new file mode 100644 index 000000000..ddae3a1c9 --- /dev/null +++ b/lib/server/runtime-platform-config.ts @@ -0,0 +1,148 @@ +import type { PlatformConfig } from "@tscircuit/props" + +const RUNTIME_PLATFORM_CONFIG_CALL_PATH = "/__tsci/runtime-platform-config/call" + +const SENSITIVE_KEY_PATTERN = /token|secret|password/i + +const getValueAtPath = (value: unknown, path: string[]) => { + let current = value + for (const segment of path) { + if (!current || typeof current !== "object") { + return undefined + } + current = (current as Record)[segment] + } + return current +} + +const hasSerializableChildren = (value: unknown): boolean => { + if (typeof value === "function") return true + if (Array.isArray(value)) { + return value.some((item) => hasSerializableChildren(item)) + } + if (!value || typeof value !== "object") return true + + for (const [key, child] of Object.entries(value)) { + if (SENSITIVE_KEY_PATTERN.test(key)) continue + if (hasSerializableChildren(child)) return true + } + + return false +} + +const serializeClientValue = (value: unknown, path: string[]): string => { + if (typeof value === "function") { + return `async (...args) => { + const response = await fetch(${JSON.stringify( + RUNTIME_PLATFORM_CONFIG_CALL_PATH, + )}, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: ${JSON.stringify(path)}, args }), + }) + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error ?? "Failed to call runtime platform config function") + } + return payload.result + }` + } + + if (value === undefined) { + return "undefined" + } + + if ( + value === null || + typeof value === "boolean" || + typeof value === "number" + ) { + return JSON.stringify(value) + } + + if (typeof value === "string") { + return JSON.stringify(value) + } + + if (Array.isArray(value)) { + return `[${value.map((item, index) => serializeClientValue(item, [...path, String(index)])).join(",")}]` + } + + if (!value || typeof value !== "object") { + return "undefined" + } + + const entries = Object.entries(value) + .filter( + ([key, child]) => + !SENSITIVE_KEY_PATTERN.test(key) && hasSerializableChildren(child), + ) + .map( + ([key, child]) => + `${JSON.stringify(key)}:${serializeClientValue(child, [...path, key])}`, + ) + + return `{${entries.join(",")}}` +} + +export const createRuntimePlatformConfigClientScript = ( + platformConfig?: PlatformConfig, +) => { + if (!platformConfig) return "" + + return ` + window.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG = ${serializeClientValue(platformConfig, [])}; + ` +} + +export const invokeRuntimePlatformConfigFunction = async ( + platformConfig: PlatformConfig | undefined, + path: string[], + args: unknown[], +) => { + if (!platformConfig) { + throw new Error("No runtime platform config is available") + } + + const parentPath = path.slice(0, -1) + const fnName = path[path.length - 1] + const parentValue = + parentPath.length > 0 + ? getValueAtPath(platformConfig, parentPath) + : platformConfig + + if (!parentValue || typeof parentValue !== "object") { + throw new Error(`Invalid runtime platform config path: ${path.join(".")}`) + } + + const fn = (parentValue as Record)[fnName] + if (typeof fn !== "function") { + throw new Error( + `Runtime platform config path is not callable: ${path.join(".")}`, + ) + } + + return await fn.apply(parentValue, args) +} + +export const patchStandaloneForRuntimePlatformConfig = ( + standaloneContent: string, +) => { + if (standaloneContent.includes("TSCIRCUIT_RUNTIME_PLATFORM_CONFIG")) { + return standaloneContent + } + + const originalSnippet = + "if(t.projectConfig)for(const _ of f4e(t.projectConfig))await s.setProjectConfigProperty(_,l(BEt(t.projectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" + + const replacementSnippet = + "const __tsciRuntimeProjectConfig=t.projectConfig??globalThis.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG;if(__tsciRuntimeProjectConfig)for(const _ of f4e(__tsciRuntimeProjectConfig))await s.setProjectConfigProperty(_,l(BEt(__tsciRuntimeProjectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" + + if (!standaloneContent.includes(originalSnippet)) { + throw new Error( + "Unable to patch runframe standalone bundle for runtime platform config", + ) + } + + return standaloneContent.replace(originalSnippet, replacementSnippet) +} diff --git a/lib/site/getIndex.ts b/lib/site/getIndex.ts index 1eeb38d8f..d985f88e6 100644 --- a/lib/site/getIndex.ts +++ b/lib/site/getIndex.ts @@ -3,6 +3,7 @@ import { getSessionToken } from "lib/cli-config" export const getIndex = async ( mainComponentPath?: string, fileServerApiBaseUrl?: string, + runtimePlatformConfigScript?: string, ) => { const sessionToken = getSessionToken() const tokenScript = sessionToken @@ -20,6 +21,7 @@ export const getIndex = async (
loading...
diff --git a/tests/test4-get-index-token.test.ts b/tests/test4-get-index-token.test.ts index 087b4bbcb..a9baca411 100644 --- a/tests/test4-get-index-token.test.ts +++ b/tests/test4-get-index-token.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test" import { cliConfig } from "lib/cli-config" +import { createRuntimePlatformConfigClientScript } from "lib/server/runtime-platform-config" import { getIndex } from "lib/site/getIndex" import { getStaticIndexHtmlFile } from "lib/site/getStaticIndexHtmlFile" @@ -24,6 +25,28 @@ test("getIndex does not inject registry token when logged out", async () => { expect(html).not.toContain("TSCIRCUIT_REGISTRY_TOKEN") }) +test("getIndex injects runtime platform config without sensitive values", async () => { + const html = await getIndex( + "index.circuit.tsx", + "http://localhost:3020/api", + createRuntimePlatformConfigClientScript({ + partsEngine: { + findPart: async () => ({ mpn: "LM358" }), + }, + footprintLibraryMap: { + ti: async () => ({ footprintCircuitJson: [] }), + }, + tiPartsEngineConfig: { + partnerToken: "secret-token", + } as any, + } as any), + ) + + expect(html).toContain("window.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG") + expect(html).toContain("__tsci/runtime-platform-config/call") + expect(html).not.toContain("secret-token") +}) + test("getStaticIndexHtmlFile output includes file list and omits registry token", () => { const originalToken = cliConfig.get("sessionToken") cliConfig.set("sessionToken", DUMMY_TOKEN) From 220c5c285063c19a42dfb34dab3896815dbcf5ed Mon Sep 17 00:00:00 2001 From: techmannih Date: Wed, 3 Jun 2026 05:07:45 +0530 Subject: [PATCH 2/4] inject runframe config --- lib/server/runtime-platform-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/runtime-platform-config.ts b/lib/server/runtime-platform-config.ts index ddae3a1c9..cb607d00d 100644 --- a/lib/server/runtime-platform-config.ts +++ b/lib/server/runtime-platform-config.ts @@ -136,7 +136,7 @@ export const patchStandaloneForRuntimePlatformConfig = ( "if(t.projectConfig)for(const _ of f4e(t.projectConfig))await s.setProjectConfigProperty(_,l(BEt(t.projectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" const replacementSnippet = - "const __tsciRuntimeProjectConfig=t.projectConfig??globalThis.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG;if(__tsciRuntimeProjectConfig)for(const _ of f4e(__tsciRuntimeProjectConfig))await s.setProjectConfigProperty(_,l(BEt(__tsciRuntimeProjectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" + "const __tsciRuntimeProjectConfig={...(globalThis.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG??{}),...(t.projectConfig??{})};if(Object.keys(__tsciRuntimeProjectConfig).length>0)for(const _ of f4e(__tsciRuntimeProjectConfig))await s.setProjectConfigProperty(_,l(BEt(__tsciRuntimeProjectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" if (!standaloneContent.includes(originalSnippet)) { throw new Error( From 0aa1cabe94da1a5e9b75603b8df44e0e43291b1f Mon Sep 17 00:00:00 2001 From: techmannih Date: Wed, 3 Jun 2026 09:28:46 +0530 Subject: [PATCH 3/4] Use config module dependencies in dev upload path --- lib/dev/DevServer.ts | 22 --- lib/server/createHttpServer.ts | 63 +------- lib/server/runtime-platform-config.ts | 148 ------------------ lib/site/getIndex.ts | 2 - ...-server-runtime-config-module-deps.test.ts | 118 ++++++++++++++ tests/test4-get-index-token.test.ts | 23 --- 6 files changed, 120 insertions(+), 256 deletions(-) delete mode 100644 lib/server/runtime-platform-config.ts create mode 100644 tests/cli/dev/dev-server-runtime-config-module-deps.test.ts diff --git a/lib/dev/DevServer.ts b/lib/dev/DevServer.ts index 2586a20a6..c64711088 100644 --- a/lib/dev/DevServer.ts +++ b/lib/dev/DevServer.ts @@ -5,7 +5,6 @@ import * as chokidar from "chokidar" import Debug from "debug" import kleur from "kleur" import ky, { TimeoutError } from "ky" -import type { PlatformConfig } from "@tscircuit/props" import { setSessionToken } from "lib/cli-config" import { getAllNodeModuleFilePaths } from "lib/dependency-analysis/getNodeModuleDependencies" import type { @@ -16,7 +15,6 @@ import type { FileServerRoutes } from "lib/file-server/FileServerRoutes" import { findRuntimeProjectConfigModulePath, loadProjectConfig, - loadRuntimeProjectConfig, } from "lib/project-config" import { EventsWatcher } from "lib/server/EventsWatcher" import { createHttpServer } from "lib/server/createHttpServer" @@ -48,7 +46,6 @@ export class DevServer { projectDir: string /** Paths or directory names to ignore when syncing files */ ignoredFiles: string[] - runtimePlatformConfig?: PlatformConfig runtimeConfigModulePath?: string /** Whether to enable the KiCad PCM proxy server */ @@ -101,27 +98,9 @@ export class DevServer { } async start() { - const runtimeProjectConfig = await loadRuntimeProjectConfig(this.projectDir) - this.runtimePlatformConfig = runtimeProjectConfig?.platformConfig this.runtimeConfigModulePath = findRuntimeProjectConfigModulePath(this.projectDir) ?? undefined - const runtimeIgnoredFiles = runtimeProjectConfig?.ignoredFiles ?? [] - this.ignoredFiles = [...runtimeIgnoredFiles] - - if (this.runtimeConfigModulePath) { - const relativeConfigPath = path.relative( - this.projectDir, - this.runtimeConfigModulePath, - ) - if ( - relativeConfigPath && - !this.ignoredFiles.includes(relativeConfigPath) - ) { - this.ignoredFiles.push(relativeConfigPath) - } - } - const { server } = await createHttpServer({ port: this.port, defaultMainComponentPath: path.relative( @@ -131,7 +110,6 @@ export class DevServer { kicadPcm: this.kicadPcm, projectDir: this.projectDir, entryFile: this.componentFilePath, - runtimePlatformConfig: this.runtimePlatformConfig, }) this.httpServer = server diff --git a/lib/server/createHttpServer.ts b/lib/server/createHttpServer.ts index d615bfaad..8b88cec2d 100644 --- a/lib/server/createHttpServer.ts +++ b/lib/server/createHttpServer.ts @@ -1,6 +1,5 @@ import * as fs from "node:fs" import * as http from "node:http" -import type { PlatformConfig } from "@tscircuit/props" // @ts-ignore import runFrameStandaloneBundleContent from "@tscircuit/runframe/standalone" with { type: "text", @@ -12,11 +11,6 @@ import pkg from "../../package.json" import winterspecBundle from "@tscircuit/file-server/dist/bundle.js" import { getIndex } from "../site/getIndex" import { createKicadPcmProxy } from "./kicad-pcm-proxy" -import { - createRuntimePlatformConfigClientScript, - invokeRuntimePlatformConfigFunction, - patchStandaloneForRuntimePlatformConfig, -} from "./runtime-platform-config" export const createHttpServer = async ({ port = 3020, @@ -24,14 +18,12 @@ export const createHttpServer = async ({ kicadPcm, projectDir, entryFile, - runtimePlatformConfig, }: { port?: number defaultMainComponentPath?: string kicadPcm?: boolean projectDir?: string entryFile?: string - runtimePlatformConfig?: PlatformConfig }) => { const fileServerHandler = getNodeHandler(winterspecBundle as any, {}) @@ -114,52 +106,6 @@ export const createHttpServer = async ({ } } - if ( - url.pathname === "/__tsci/runtime-platform-config/call" && - req.method === "POST" - ) { - try { - const chunks: Buffer[] = [] - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - } - - const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { - path?: string[] - args?: unknown[] - } - - if (!Array.isArray(body.path)) { - res.writeHead(400, { "Content-Type": "application/json" }) - res.end( - JSON.stringify({ error: "Invalid runtime platform config path" }), - ) - return - } - - const result = await invokeRuntimePlatformConfigFunction( - runtimePlatformConfig, - body.path, - Array.isArray(body.args) ? body.args : [], - ) - - res.writeHead(200, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ result })) - return - } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }) - res.end( - JSON.stringify({ - error: - error instanceof Error - ? error.message - : String(error ?? "Unknown error"), - }), - ) - return - } - } - if (url.pathname === "/standalone.min.js") { const standaloneFilePath = process.env.RUNFRAME_STANDALONE_FILE_PATH @@ -167,11 +113,7 @@ export const createHttpServer = async ({ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", }) - res.end( - patchStandaloneForRuntimePlatformConfig( - runFrameStandaloneBundleContent, - ), - ) + res.end(runFrameStandaloneBundleContent) return } @@ -180,7 +122,7 @@ export const createHttpServer = async ({ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", }) - res.end(patchStandaloneForRuntimePlatformConfig(content)) + res.end(content) return } catch (error) { console.info( @@ -200,7 +142,6 @@ export const createHttpServer = async ({ const html = await getIndex( defaultMainComponentPath, fileServerApiBaseUrl, - createRuntimePlatformConfigClientScript(runtimePlatformConfig), ) res.writeHead(200, { "Content-Type": "text/html" }) res.end(html) diff --git a/lib/server/runtime-platform-config.ts b/lib/server/runtime-platform-config.ts deleted file mode 100644 index cb607d00d..000000000 --- a/lib/server/runtime-platform-config.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { PlatformConfig } from "@tscircuit/props" - -const RUNTIME_PLATFORM_CONFIG_CALL_PATH = "/__tsci/runtime-platform-config/call" - -const SENSITIVE_KEY_PATTERN = /token|secret|password/i - -const getValueAtPath = (value: unknown, path: string[]) => { - let current = value - for (const segment of path) { - if (!current || typeof current !== "object") { - return undefined - } - current = (current as Record)[segment] - } - return current -} - -const hasSerializableChildren = (value: unknown): boolean => { - if (typeof value === "function") return true - if (Array.isArray(value)) { - return value.some((item) => hasSerializableChildren(item)) - } - if (!value || typeof value !== "object") return true - - for (const [key, child] of Object.entries(value)) { - if (SENSITIVE_KEY_PATTERN.test(key)) continue - if (hasSerializableChildren(child)) return true - } - - return false -} - -const serializeClientValue = (value: unknown, path: string[]): string => { - if (typeof value === "function") { - return `async (...args) => { - const response = await fetch(${JSON.stringify( - RUNTIME_PLATFORM_CONFIG_CALL_PATH, - )}, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: ${JSON.stringify(path)}, args }), - }) - const payload = await response.json() - if (!response.ok) { - throw new Error(payload.error ?? "Failed to call runtime platform config function") - } - return payload.result - }` - } - - if (value === undefined) { - return "undefined" - } - - if ( - value === null || - typeof value === "boolean" || - typeof value === "number" - ) { - return JSON.stringify(value) - } - - if (typeof value === "string") { - return JSON.stringify(value) - } - - if (Array.isArray(value)) { - return `[${value.map((item, index) => serializeClientValue(item, [...path, String(index)])).join(",")}]` - } - - if (!value || typeof value !== "object") { - return "undefined" - } - - const entries = Object.entries(value) - .filter( - ([key, child]) => - !SENSITIVE_KEY_PATTERN.test(key) && hasSerializableChildren(child), - ) - .map( - ([key, child]) => - `${JSON.stringify(key)}:${serializeClientValue(child, [...path, key])}`, - ) - - return `{${entries.join(",")}}` -} - -export const createRuntimePlatformConfigClientScript = ( - platformConfig?: PlatformConfig, -) => { - if (!platformConfig) return "" - - return ` - window.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG = ${serializeClientValue(platformConfig, [])}; - ` -} - -export const invokeRuntimePlatformConfigFunction = async ( - platformConfig: PlatformConfig | undefined, - path: string[], - args: unknown[], -) => { - if (!platformConfig) { - throw new Error("No runtime platform config is available") - } - - const parentPath = path.slice(0, -1) - const fnName = path[path.length - 1] - const parentValue = - parentPath.length > 0 - ? getValueAtPath(platformConfig, parentPath) - : platformConfig - - if (!parentValue || typeof parentValue !== "object") { - throw new Error(`Invalid runtime platform config path: ${path.join(".")}`) - } - - const fn = (parentValue as Record)[fnName] - if (typeof fn !== "function") { - throw new Error( - `Runtime platform config path is not callable: ${path.join(".")}`, - ) - } - - return await fn.apply(parentValue, args) -} - -export const patchStandaloneForRuntimePlatformConfig = ( - standaloneContent: string, -) => { - if (standaloneContent.includes("TSCIRCUIT_RUNTIME_PLATFORM_CONFIG")) { - return standaloneContent - } - - const originalSnippet = - "if(t.projectConfig)for(const _ of f4e(t.projectConfig))await s.setProjectConfigProperty(_,l(BEt(t.projectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" - - const replacementSnippet = - "const __tsciRuntimeProjectConfig={...(globalThis.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG??{}),...(t.projectConfig??{})};if(Object.keys(__tsciRuntimeProjectConfig).length>0)for(const _ of f4e(__tsciRuntimeProjectConfig))await s.setProjectConfigProperty(_,l(BEt(__tsciRuntimeProjectConfig,_))).catch(h=>{throw new Error(`Error setting project config property ${_}: ${h instanceof Error?h.message:String(h)}`)})" - - if (!standaloneContent.includes(originalSnippet)) { - throw new Error( - "Unable to patch runframe standalone bundle for runtime platform config", - ) - } - - return standaloneContent.replace(originalSnippet, replacementSnippet) -} diff --git a/lib/site/getIndex.ts b/lib/site/getIndex.ts index d985f88e6..1eeb38d8f 100644 --- a/lib/site/getIndex.ts +++ b/lib/site/getIndex.ts @@ -3,7 +3,6 @@ import { getSessionToken } from "lib/cli-config" export const getIndex = async ( mainComponentPath?: string, fileServerApiBaseUrl?: string, - runtimePlatformConfigScript?: string, ) => { const sessionToken = getSessionToken() const tokenScript = sessionToken @@ -21,7 +20,6 @@ export const getIndex = async (
loading...
diff --git a/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts b/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts new file mode 100644 index 000000000..de8616501 --- /dev/null +++ b/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test" +import { DevServer } from "cli/dev/DevServer" +import getPort from "get-port" +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +test("dev server uploads node_modules dependencies imported by tscircuit.config.ts", async () => { + const fixture = await getCliTestFixture() + const projectDir = fixture.tmpDir + + await writeFile( + join(projectDir, "index.circuit.tsx"), + ` +export default () => ( + + + +) +`, + ) + + await writeFile( + join(projectDir, "tscircuit.config.ts"), + ` +import { createTiPlatformConfig } from "@tscircuit/ti-parts-engine" + +export default { + platformConfig: createTiPlatformConfig({ + partnerToken: "secret-token", + }), +} +`, + ) + + await writeFile( + join(projectDir, "package.json"), + JSON.stringify( + { + name: "test-project", + version: "1.0.0", + dependencies: { + "@tscircuit/ti-parts-engine": "1.0.0", + }, + }, + null, + 2, + ), + ) + + const pkgDir = join(projectDir, "node_modules", "@tscircuit", "ti-parts-engine") + const libDir = join(pkgDir, "lib", "ti-parts-engine") + await mkdir(libDir, { recursive: true }) + + await writeFile( + join(pkgDir, "package.json"), + JSON.stringify( + { + name: "@tscircuit/ti-parts-engine", + version: "1.0.0", + main: "./index.ts", + module: "./index.ts", + exports: { + ".": { + import: "./index.ts", + types: "./index.ts", + }, + }, + }, + null, + 2, + ), + ) + + await writeFile( + join(pkgDir, "index.ts"), + ` +export { createTiPlatformConfig } from "./lib/ti-parts-engine/createTiPlatformConfig" +`, + ) + + await writeFile( + join(libDir, "createTiPlatformConfig.ts"), + ` +export const createTiPlatformConfig = (options: { partnerToken: string }) => ({ + footprintLibraryMap: { + ti: async () => ({ + footprintCircuitJson: [], + partnerToken: options.partnerToken, + }), + }, +}) +`, + ) + + const devServer = new DevServer({ + port: await getPort(), + componentFilePath: join(projectDir, "index.circuit.tsx"), + }) + + try { + await devServer.start() + + const { file_list } = (await devServer.fsKy + .get("api/files/list") + .json()) as { file_list: Array<{ file_path: string }> } + + const filePaths = file_list.map((f) => f.file_path) + + expect(filePaths).toContain("tscircuit.config.ts") + expect(filePaths).toContain("node_modules/@tscircuit/ti-parts-engine/index.ts") + expect(filePaths).toContain( + "node_modules/@tscircuit/ti-parts-engine/lib/ti-parts-engine/createTiPlatformConfig.ts", + ) + } finally { + await devServer.stop() + } +}, 30_000) diff --git a/tests/test4-get-index-token.test.ts b/tests/test4-get-index-token.test.ts index a9baca411..087b4bbcb 100644 --- a/tests/test4-get-index-token.test.ts +++ b/tests/test4-get-index-token.test.ts @@ -1,6 +1,5 @@ import { test, expect } from "bun:test" import { cliConfig } from "lib/cli-config" -import { createRuntimePlatformConfigClientScript } from "lib/server/runtime-platform-config" import { getIndex } from "lib/site/getIndex" import { getStaticIndexHtmlFile } from "lib/site/getStaticIndexHtmlFile" @@ -25,28 +24,6 @@ test("getIndex does not inject registry token when logged out", async () => { expect(html).not.toContain("TSCIRCUIT_REGISTRY_TOKEN") }) -test("getIndex injects runtime platform config without sensitive values", async () => { - const html = await getIndex( - "index.circuit.tsx", - "http://localhost:3020/api", - createRuntimePlatformConfigClientScript({ - partsEngine: { - findPart: async () => ({ mpn: "LM358" }), - }, - footprintLibraryMap: { - ti: async () => ({ footprintCircuitJson: [] }), - }, - tiPartsEngineConfig: { - partnerToken: "secret-token", - } as any, - } as any), - ) - - expect(html).toContain("window.TSCIRCUIT_RUNTIME_PLATFORM_CONFIG") - expect(html).toContain("__tsci/runtime-platform-config/call") - expect(html).not.toContain("secret-token") -}) - test("getStaticIndexHtmlFile output includes file list and omits registry token", () => { const originalToken = cliConfig.get("sessionToken") cliConfig.set("sessionToken", DUMMY_TOKEN) From 91a677aa83cf05b8fb6ec82a7a7e60f3614501e1 Mon Sep 17 00:00:00 2001 From: techmannih Date: Wed, 3 Jun 2026 10:10:55 +0530 Subject: [PATCH 4/4] Resolve project config from input file paths --- cli/dev/resolve-dev-target.ts | 2 + cli/export/register.ts | 8 +++- cli/simulate/register.ts | 8 +++- lib/project-config/index.ts | 32 ++++++++++++++++ ...-server-runtime-config-module-deps.test.ts | 11 +++++- .../resolve-dev-target-project-dir.test.ts | 37 +++++++++++++++++++ ...ntime-project-config-command-flows.test.ts | 35 ++++++++++++++++++ 7 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 tests/cli/dev/resolve-dev-target-project-dir.test.ts diff --git a/cli/dev/resolve-dev-target.ts b/cli/dev/resolve-dev-target.ts index abdcaf236..13c3ad947 100644 --- a/cli/dev/resolve-dev-target.ts +++ b/cli/dev/resolve-dev-target.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs" import * as path from "node:path" import { globbySync } from "globby" +import { resolveProjectDirFromInputPath } from "lib/project-config" import { findBoardFiles } from "lib/shared/find-board-files" import { getEntrypoint } from "lib/shared/get-entrypoint" import { DEFAULT_IGNORED_PATTERNS } from "lib/shared/should-ignore-path" @@ -81,6 +82,7 @@ export const resolveDevTarget = async ( return null } + projectDir = resolveProjectDirFromInputPath(resolvedPath) return { absolutePath: resolvedPath, projectDir } } diff --git a/cli/export/register.ts b/cli/export/register.ts index 1a3d79b2d..0f752c665 100644 --- a/cli/export/register.ts +++ b/cli/export/register.ts @@ -9,7 +9,10 @@ import { resultToCsv } from "lib/shared/result-to-csv" import path from "node:path" import { promises as fs } from "node:fs" import type { PlatformConfig } from "@tscircuit/props" -import { loadRuntimeProjectConfig } from "lib/project-config" +import { + loadRuntimeProjectConfig, + resolveProjectDirFromInputPath, +} from "lib/project-config" import { mergePlatformConfigs } from "lib/shared/platform-config-utils" export const registerExport = (program: Command) => { @@ -35,7 +38,8 @@ export const registerExport = (program: Command) => { }, ) => { const formatOption = options.format ?? "json" - const projectConfig = await loadRuntimeProjectConfig(process.cwd()) + const projectDir = resolveProjectDirFromInputPath(file) + const projectConfig = await loadRuntimeProjectConfig(projectDir) const commandPlatformConfig: PlatformConfig | undefined = options.disablePartsEngine === true diff --git a/cli/simulate/register.ts b/cli/simulate/register.ts index 24c5c84e9..745f3ca23 100644 --- a/cli/simulate/register.ts +++ b/cli/simulate/register.ts @@ -4,7 +4,10 @@ import { runSimulation } from "lib/eecircuit-engine/run-simulation" import { resultToTable } from "lib/shared/result-to-table" import { getSpiceWithPaddedSim } from "lib/shared/get-spice-with-sim" import type { PlatformConfig } from "@tscircuit/props" -import { loadRuntimeProjectConfig } from "lib/project-config" +import { + loadRuntimeProjectConfig, + resolveProjectDirFromInputPath, +} from "lib/project-config" import { mergePlatformConfigs } from "lib/shared/platform-config-utils" export const registerSimulate = (program: Command) => { @@ -18,7 +21,8 @@ export const registerSimulate = (program: Command) => { .argument("", "Path to tscircuit tsx or circuit json file") .option("--disable-parts-engine", "Disable the parts engine") .action(async (file: string, options: { disablePartsEngine?: boolean }) => { - const projectConfig = await loadRuntimeProjectConfig(process.cwd()) + const projectDir = resolveProjectDirFromInputPath(file) + const projectConfig = await loadRuntimeProjectConfig(projectDir) const commandPlatformConfig: PlatformConfig | undefined = options.disablePartsEngine === true ? { partsEngineDisabled: true } diff --git a/lib/project-config/index.ts b/lib/project-config/index.ts index 89f368b14..b92088d06 100644 --- a/lib/project-config/index.ts +++ b/lib/project-config/index.ts @@ -155,6 +155,38 @@ export const findRuntimeProjectConfigModulePath = ( return null } +export const resolveProjectDirFromInputPath = ( + inputPath: string, + cwd: string = process.cwd(), +): string => { + const resolvedPath = path.isAbsolute(inputPath) + ? inputPath + : path.resolve(cwd, inputPath) + + const fallbackDir = + fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory() + ? resolvedPath + : path.dirname(resolvedPath) + + let currentDir = fallbackDir + + while (true) { + if ( + findRuntimeProjectConfigModulePath(currentDir) || + fs.existsSync(path.join(currentDir, CONFIG_FILENAME)) || + fs.existsSync(path.join(currentDir, "package.json")) + ) { + return currentDir + } + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) { + return fallbackDir + } + currentDir = parentDir + } +} + export const loadRuntimeProjectConfig = async ( projectDir: string = process.cwd(), ): Promise => { diff --git a/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts b/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts index de8616501..33cd0838f 100644 --- a/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts +++ b/tests/cli/dev/dev-server-runtime-config-module-deps.test.ts @@ -48,7 +48,12 @@ export default { ), ) - const pkgDir = join(projectDir, "node_modules", "@tscircuit", "ti-parts-engine") + const pkgDir = join( + projectDir, + "node_modules", + "@tscircuit", + "ti-parts-engine", + ) const libDir = join(pkgDir, "lib", "ti-parts-engine") await mkdir(libDir, { recursive: true }) @@ -108,7 +113,9 @@ export const createTiPlatformConfig = (options: { partnerToken: string }) => ({ const filePaths = file_list.map((f) => f.file_path) expect(filePaths).toContain("tscircuit.config.ts") - expect(filePaths).toContain("node_modules/@tscircuit/ti-parts-engine/index.ts") + expect(filePaths).toContain( + "node_modules/@tscircuit/ti-parts-engine/index.ts", + ) expect(filePaths).toContain( "node_modules/@tscircuit/ti-parts-engine/lib/ti-parts-engine/createTiPlatformConfig.ts", ) diff --git a/tests/cli/dev/resolve-dev-target-project-dir.test.ts b/tests/cli/dev/resolve-dev-target-project-dir.test.ts new file mode 100644 index 000000000..868a9b209 --- /dev/null +++ b/tests/cli/dev/resolve-dev-target-project-dir.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { resolveDevTarget } from "cli/dev/resolve-dev-target" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +test("resolveDevTarget uses the input file project directory", async () => { + const { tmpDir } = await getCliTestFixture() + const nestedProjectDir = join(tmpDir, "nested-project") + const circuitPath = join(nestedProjectDir, "index.circuit.tsx") + + await mkdir(nestedProjectDir, { recursive: true }) + await writeFile( + join(nestedProjectDir, "package.json"), + JSON.stringify({ name: "nested-project" }), + ) + await writeFile( + join(nestedProjectDir, "tscircuit.config.ts"), + "export default { includeBoardFiles: ['**/*.circuit.tsx'] }\n", + ) + await writeFile( + circuitPath, + 'export default () => \n', + ) + + const originalCwd = process.cwd() + process.chdir(tmpDir) + + try { + const resolved = await resolveDevTarget(circuitPath) + expect(resolved).not.toBeNull() + expect(resolved?.absolutePath).toBe(circuitPath) + expect(resolved?.projectDir).toBe(nestedProjectDir) + } finally { + process.chdir(originalCwd) + } +}) diff --git a/tests/cli/runtime-project-config-command-flows.test.ts b/tests/cli/runtime-project-config-command-flows.test.ts index 1255cedf7..2e0ee0da1 100644 --- a/tests/cli/runtime-project-config-command-flows.test.ts +++ b/tests/cli/runtime-project-config-command-flows.test.ts @@ -260,3 +260,38 @@ test("simulate analog consumes runtime platformConfig from tscircuit.config.ts", expect(stderr).toContain("source_port_id") expect(stdout).toContain("Index time") }, 30_000) + +test("export resolves runtime project config from the input file project directory", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + const nestedProjectDir = join(tmpDir, "nested-project") + const circuitPath = join(nestedProjectDir, "index.circuit.tsx") + + await mkdir(nestedProjectDir, { recursive: true }) + await writeFile( + join(nestedProjectDir, "package.json"), + JSON.stringify({ name: "nested-project" }), + ) + await writeFile(circuitPath, tiBoardCircuitCode) + await writeFile( + join(nestedProjectDir, "tscircuit.config.ts"), + createTiPlatformConfigModule(), + ) + + const { stderr, exitCode } = await runCommand( + `tsci export ${circuitPath} -f circuit-json`, + ) + + expect(exitCode).toBe(0) + expect(stderr).toBe("") + + const circuitJson = JSON.parse( + await readFile( + join(nestedProjectDir, "index.circuit.circuit.json"), + "utf-8", + ), + ) + + expect( + circuitJson.some((element: any) => element.type === "pcb_smtpad"), + ).toBe(true) +}, 30_000)