diff --git a/packages/pi-fff/src/sdk.ts b/packages/pi-fff/src/sdk.ts index af2d1620..052bf29f 100644 --- a/packages/pi-fff/src/sdk.ts +++ b/packages/pi-fff/src/sdk.ts @@ -9,6 +9,22 @@ 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"], +}; + +// 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 ( @@ -19,6 +35,26 @@ function detectRuntime(): "bun" | "node" { return "node"; } +/** Preferred SDK order for the detected runtime. */ +export function sdkCandidates(): readonly [string, string] { + return SDK_ORDER[detectRuntime()]; +} + +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 loaders[pkg]()) as { FileFinder: FileFinderStatic }; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> { if (sdkPromise) return sdkPromise; @@ -34,8 +70,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..3c6ce512 --- /dev/null +++ b/packages/pi-fff/test/sdk.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { loadFirst, sdkCandidates } from "../src/sdk"; + +describe("sdkCandidates", () => { + test("defaults to bun candidates under a Bun runtime", () => { + expect(sdkCandidates()).toEqual(["@ff-labs/fff-bun", "@ff-labs/fff-node"]); + }); +}); + +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("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("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 () => { + 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"); + }); +});