From 3d1360a581021a9476316bfba1a6465efd794d24 Mon Sep 17 00:00:00 2001 From: manooog Date: Sat, 15 Aug 2026 16:13:32 +0800 Subject: [PATCH 1/4] fix(pi-fff): fall back to fff-node when fff-bun cannot be imported Bun-compiled hosts (e.g. omp, the Oh My Pi harness) expose globalThis.Bun but their module resolver rejects TypeScript entry points under node_modules, so importing @ff-labs/fff-bun (TS-source only) fails with "Cannot find module '@ff-labs/fff-bun'". loadSdk() now tries the preferred SDK first and falls back to the other one, so such hosts transparently use the JS-compiled @ff-labs/fff-node. The preferred order stays runtime-detected and can be forced via FFF_SDK=bun|node. Refs #778 --- packages/pi-fff/src/sdk.ts | 31 +++++++++++++++++++++++++++++-- packages/pi-fff/test/sdk.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/pi-fff/test/sdk.test.ts diff --git a/packages/pi-fff/src/sdk.ts b/packages/pi-fff/src/sdk.ts index af2d1620..67f6952b 100644 --- a/packages/pi-fff/src/sdk.ts +++ b/packages/pi-fff/src/sdk.ts @@ -9,6 +9,14 @@ export type FileFinderStatic = { let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null; +const SDK_ORDER: Record<"bun" | "node", readonly [string, string]> = { + // fff-bun is TS-source only and cannot be imported by Bun-compiled hosts + // (e.g. omp) whose module resolver rejects .ts under node_modules, so the + // JS-compiled fff-node is kept as a fallback for every runtime. + bun: ["@ff-labs/fff-bun", "@ff-labs/fff-node"], + node: ["@ff-labs/fff-node", "@ff-labs/fff-bun"], +}; + function detectRuntime(): "bun" | "node" { if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun"; if ( @@ -19,6 +27,26 @@ function detectRuntime(): "bun" | "node" { return "node"; } +/** Preferred SDK order, overridable via FFF_SDK=bun|node. */ +export function sdkCandidates(): readonly [string, string] { + const forced = process.env.FFF_SDK; + return SDK_ORDER[forced === "node" ? "node" : forced === "bun" ? "bun" : detectRuntime()]; +} + +async function loadFirst( + candidates: readonly [string, string], +): Promise<{ FileFinder: FileFinderStatic }> { + let lastError: unknown; + for (const pkg of candidates) { + try { + return (await import(pkg)) as { FileFinder: FileFinderStatic }; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> { if (sdkPromise) return sdkPromise; @@ -34,8 +62,7 @@ export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> { } // default to node as it seems like default option - const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node"; - const p = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>; + const p = loadFirst(sdkCandidates()); sdkPromise = p; (globalThis as Record).__fffSdkPromiseGlobal = p; return p; diff --git a/packages/pi-fff/test/sdk.test.ts b/packages/pi-fff/test/sdk.test.ts new file mode 100644 index 00000000..81e98ff1 --- /dev/null +++ b/packages/pi-fff/test/sdk.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { sdkCandidates } from "../src/sdk"; + +describe("sdkCandidates", () => { + const original = process.env.FFF_SDK; + + beforeEach(() => { + process.env.FFF_SDK = original; + }); + + test("defaults to bun candidates under a Bun runtime", () => { + delete process.env.FFF_SDK; + expect(sdkCandidates()).toEqual(["@ff-labs/fff-bun", "@ff-labs/fff-node"]); + }); + + test("FFF_SDK=node forces the node candidates", () => { + process.env.FFF_SDK = "node"; + expect(sdkCandidates()).toEqual(["@ff-labs/fff-node", "@ff-labs/fff-bun"]); + }); + + test("FFF_SDK=bun forces the bun candidates", () => { + process.env.FFF_SDK = "bun"; + expect(sdkCandidates()).toEqual(["@ff-labs/fff-bun", "@ff-labs/fff-node"]); + }); + + test("unknown FFF_SDK falls back to runtime detection", () => { + process.env.FFF_SDK = "nope"; + // test runner is Bun, so runtime detection yields the bun candidates + expect(sdkCandidates()[0]).toBe("@ff-labs/fff-bun"); + }); +}); From 612b32f16cbb97dc7e035ca94bda2651d1e31131 Mon Sep 17 00:00:00 2001 From: manooog Date: Sat, 15 Aug 2026 16:14:39 +0800 Subject: [PATCH 2/4] test(pi-fff): cover SDK fallback and candidate selection --- packages/pi-fff/src/sdk.ts | 2 +- packages/pi-fff/test/sdk.test.ts | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/pi-fff/src/sdk.ts b/packages/pi-fff/src/sdk.ts index 67f6952b..f9d6578c 100644 --- a/packages/pi-fff/src/sdk.ts +++ b/packages/pi-fff/src/sdk.ts @@ -33,7 +33,7 @@ export function sdkCandidates(): readonly [string, string] { return SDK_ORDER[forced === "node" ? "node" : forced === "bun" ? "bun" : detectRuntime()]; } -async function loadFirst( +export async function loadFirst( candidates: readonly [string, string], ): Promise<{ FileFinder: FileFinderStatic }> { let lastError: unknown; diff --git a/packages/pi-fff/test/sdk.test.ts b/packages/pi-fff/test/sdk.test.ts index 81e98ff1..bf515ba7 100644 --- a/packages/pi-fff/test/sdk.test.ts +++ b/packages/pi-fff/test/sdk.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { sdkCandidates } from "../src/sdk"; +import { loadFirst, sdkCandidates } from "../src/sdk"; describe("sdkCandidates", () => { const original = process.env.FFF_SDK; @@ -29,3 +29,21 @@ describe("sdkCandidates", () => { expect(sdkCandidates()[0]).toBe("@ff-labs/fff-bun"); }); }); + +describe("loadFirst", () => { + test("falls back to the second candidate when the first cannot be imported", async () => { + const mod = await loadFirst(["@ff-labs/does-not-exist-in-this-graph", "node:path"]); + expect(mod).toHaveProperty("resolve"); + }); + + test("prefers the first candidate when both are importable", async () => { + const mod = await loadFirst(["node:path", "node:fs"]); + expect(mod).toHaveProperty("resolve"); + }); + + test("throws the last error when every candidate fails", async () => { + await expect( + loadFirst(["@ff-labs/does-not-exist-a", "@ff-labs/does-not-exist-b"]), + ).rejects.toThrow(); + }); +}); From f8b560b4597f80f56382f6ccce2ba54981307c7f Mon Sep 17 00:00:00 2001 From: manooog Date: Sat, 15 Aug 2026 16:20:43 +0800 Subject: [PATCH 3/4] fix(pi-fff): use literal dynamic imports so omp's static graph scan hooks the SDKs omp (Oh My Pi) loads extension modules by statically scanning their source for string-literal import specifiers, then installing load hooks that resolve external packages from the real filesystem. A variable dynamic import (`import(pkg)`) bypasses that scan, so both @ff-labs SDKs failed to load at runtime inside the Bun-compiled host, regardless of which one was chosen. Keep the SDK lookup table but route through per-package literal import loaders; the fallback and FFF_SDK override behavior is unchanged. --- packages/pi-fff/src/sdk.ts | 11 +++++++++- packages/pi-fff/test/sdk.test.ts | 37 ++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/pi-fff/src/sdk.ts b/packages/pi-fff/src/sdk.ts index f9d6578c..4e3cbf8e 100644 --- a/packages/pi-fff/src/sdk.ts +++ b/packages/pi-fff/src/sdk.ts @@ -17,6 +17,14 @@ const SDK_ORDER: Record<"bun" | "node", readonly [string, string]> = { node: ["@ff-labs/fff-node", "@ff-labs/fff-bun"], }; +// Literal dynamic imports so hosts that statically scan extension graphs +// (omp's legacy-pi-compat loader) discover and hook both SDK packages; +// a variable `import(pkg)` would bypass that scan and fail at runtime. +const SDK_IMPORTS = { + "@ff-labs/fff-bun": () => import("@ff-labs/fff-bun"), + "@ff-labs/fff-node": () => import("@ff-labs/fff-node"), +} as const; + function detectRuntime(): "bun" | "node" { if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun"; if ( @@ -35,11 +43,12 @@ export function sdkCandidates(): readonly [string, string] { export async function loadFirst( candidates: readonly [string, string], + loaders: Record Promise> = SDK_IMPORTS, ): Promise<{ FileFinder: FileFinderStatic }> { let lastError: unknown; for (const pkg of candidates) { try { - return (await import(pkg)) as { FileFinder: FileFinderStatic }; + return (await loaders[pkg]()) as { FileFinder: FileFinderStatic }; } catch (error) { lastError = error; } diff --git a/packages/pi-fff/test/sdk.test.ts b/packages/pi-fff/test/sdk.test.ts index bf515ba7..4cd0d387 100644 --- a/packages/pi-fff/test/sdk.test.ts +++ b/packages/pi-fff/test/sdk.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { beforeEach, describe, expect, test } from "bun:test"; import { loadFirst, sdkCandidates } from "../src/sdk"; @@ -30,20 +31,38 @@ describe("sdkCandidates", () => { }); }); +describe("literal SDK imports", () => { + test("both SDK specifiers appear as literal dynamic imports for static graph scans", () => { + const source = readFileSync(new URL("../src/sdk.ts", import.meta.url), "utf8"); + expect(source).toContain('import("@ff-labs/fff-bun")'); + expect(source).toContain('import("@ff-labs/fff-node")'); + }); +}); + describe("loadFirst", () => { - test("falls back to the second candidate when the first cannot be imported", async () => { - const mod = await loadFirst(["@ff-labs/does-not-exist-in-this-graph", "node:path"]); - expect(mod).toHaveProperty("resolve"); + test("prefers the first candidate when both load", async () => { + const loaders = { + a: () => Promise.resolve({ FileFinder: { create: () => "a" } }), + b: () => Promise.resolve({ FileFinder: { create: () => "b" } }), + }; + const mod = await loadFirst(["a", "b"], loaders); + expect(mod.FileFinder.create()).toBe("a"); }); - test("prefers the first candidate when both are importable", async () => { - const mod = await loadFirst(["node:path", "node:fs"]); - expect(mod).toHaveProperty("resolve"); + test("falls back to the second candidate when the first import fails", async () => { + const loaders = { + a: () => Promise.reject(new Error("cannot find a")), + b: () => Promise.resolve({ FileFinder: { create: () => "b" } }), + }; + const mod = await loadFirst(["a", "b"], loaders); + expect(mod.FileFinder.create()).toBe("b"); }); test("throws the last error when every candidate fails", async () => { - await expect( - loadFirst(["@ff-labs/does-not-exist-a", "@ff-labs/does-not-exist-b"]), - ).rejects.toThrow(); + const loaders = { + a: () => Promise.reject(new Error("first failure")), + b: () => Promise.reject(new Error("second failure")), + }; + await expect(loadFirst(["a", "b"], loaders)).rejects.toThrow("second failure"); }); }); From ed76d255f17d0ef20d87ad91a592b7432216ebfd Mon Sep 17 00:00:00 2001 From: manooog Date: Sat, 15 Aug 2026 16:29:27 +0800 Subject: [PATCH 4/4] refactor(pi-fff): drop FFF_SDK override, fallback already covers misdetection --- packages/pi-fff/src/sdk.ts | 5 ++--- packages/pi-fff/test/sdk.test.ts | 25 +------------------------ 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/packages/pi-fff/src/sdk.ts b/packages/pi-fff/src/sdk.ts index 4e3cbf8e..052bf29f 100644 --- a/packages/pi-fff/src/sdk.ts +++ b/packages/pi-fff/src/sdk.ts @@ -35,10 +35,9 @@ function detectRuntime(): "bun" | "node" { return "node"; } -/** Preferred SDK order, overridable via FFF_SDK=bun|node. */ +/** Preferred SDK order for the detected runtime. */ export function sdkCandidates(): readonly [string, string] { - const forced = process.env.FFF_SDK; - return SDK_ORDER[forced === "node" ? "node" : forced === "bun" ? "bun" : detectRuntime()]; + return SDK_ORDER[detectRuntime()]; } export async function loadFirst( diff --git a/packages/pi-fff/test/sdk.test.ts b/packages/pi-fff/test/sdk.test.ts index 4cd0d387..3c6ce512 100644 --- a/packages/pi-fff/test/sdk.test.ts +++ b/packages/pi-fff/test/sdk.test.ts @@ -1,34 +1,11 @@ import { readFileSync } from "node:fs"; -import { beforeEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { loadFirst, sdkCandidates } from "../src/sdk"; describe("sdkCandidates", () => { - const original = process.env.FFF_SDK; - - beforeEach(() => { - process.env.FFF_SDK = original; - }); - test("defaults to bun candidates under a Bun runtime", () => { - delete process.env.FFF_SDK; - expect(sdkCandidates()).toEqual(["@ff-labs/fff-bun", "@ff-labs/fff-node"]); - }); - - test("FFF_SDK=node forces the node candidates", () => { - process.env.FFF_SDK = "node"; - expect(sdkCandidates()).toEqual(["@ff-labs/fff-node", "@ff-labs/fff-bun"]); - }); - - test("FFF_SDK=bun forces the bun candidates", () => { - process.env.FFF_SDK = "bun"; expect(sdkCandidates()).toEqual(["@ff-labs/fff-bun", "@ff-labs/fff-node"]); }); - - test("unknown FFF_SDK falls back to runtime detection", () => { - process.env.FFF_SDK = "nope"; - // test runner is Bun, so runtime detection yields the bun candidates - expect(sdkCandidates()[0]).toBe("@ff-labs/fff-bun"); - }); }); describe("literal SDK imports", () => {