diff --git a/packages/typegen/skills/getting-started/SKILL.md b/packages/typegen/skills/getting-started/SKILL.md index 4eda27f7..7ce4d2b8 100644 --- a/packages/typegen/skills/getting-started/SKILL.md +++ b/packages/typegen/skills/getting-started/SKILL.md @@ -193,7 +193,7 @@ For WebViewer apps running inside FileMaker, you can use FM HTTP mode to generat **Prerequisites:** - FM HTTP daemon installed and running (`curl http://127.0.0.1:1365/health`) -- FileMaker file open locally with "Connect to MCP" script run +- FileMaker file reachable via the FM HTTP `connectedFiles` endpoint. The most common setup is to open the file locally and run a script such as "Connect to MCP", but the script name may differ in your solution as long as it establishes the bridge. 1. Install packages: diff --git a/packages/webviewer/package.json b/packages/webviewer/package.json index f2936e99..a19561b5 100644 --- a/packages/webviewer/package.json +++ b/packages/webviewer/package.json @@ -31,6 +31,16 @@ "default": "./dist/cjs/adapter.cjs" } }, + "./vite-plugins": { + "import": { + "types": "./dist/esm/vite-plugins.d.ts", + "default": "./dist/esm/vite-plugins.js" + }, + "require": { + "types": "./dist/cjs/vite-plugins.d.cts", + "default": "./dist/cjs/vite-plugins.cjs" + } + }, "./package.json": "./package.json" }, "dependencies": { @@ -47,12 +57,14 @@ "knip": "^5.80.2", "publint": "^0.3.16", "typescript": "^5.9.3", - "vite": "^6.4.1" + "vite": "^6.4.1", + "vitest": "^4.0.17" }, "publishConfig": { "access": "public" }, "scripts": { + "test": "vitest run", "typecheck": "tsc --noEmit", "prepublishOnly": "tsc", "build": "tsc && vite build && publint --strict", diff --git a/packages/webviewer/src/fm-bridge.ts b/packages/webviewer/src/fm-bridge.ts new file mode 100644 index 00000000..b9cdf9db --- /dev/null +++ b/packages/webviewer/src/fm-bridge.ts @@ -0,0 +1,155 @@ +import type { HtmlTagDescriptor, Plugin } from "vite"; + +const TRAILING_SLASH_PATTERN = /\/$/; +const CONNECTED_FILES_TIMEOUT_MS = 5000; + +export interface FmBridgeOptions { + fileName?: string; + fmHttpBaseUrl?: string; + wsUrl?: string; + debug?: boolean; +} + +export const defaultFmHttpBaseUrl = "http://localhost:1365"; +export const defaultWsUrl = "ws://localhost:1365/ws"; + +export const trimToNull = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +export const normalizeBaseUrl = (value: string): string => value.replace(TRAILING_SLASH_PATTERN, ""); + +export const resolveWsUrl = (options: Pick): string => { + const explicitWsUrl = trimToNull(options.wsUrl); + if (explicitWsUrl) { + return explicitWsUrl; + } + + const baseUrl = normalizeBaseUrl(trimToNull(options.fmHttpBaseUrl) ?? defaultFmHttpBaseUrl); + + try { + const parsed = new URL(baseUrl); + const wsProtocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + return `${wsProtocol}//${parsed.host}/ws`; + } catch { + return defaultWsUrl; + } +}; + +export const discoverConnectedFileName = async (baseUrl: string): Promise => { + const connectedFilesUrl = `${normalizeBaseUrl(baseUrl)}/connectedFiles`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, CONNECTED_FILES_TIMEOUT_MS); + const reachabilityErrorMessage = `fmBridge could not reach ${connectedFilesUrl}. Start fm-http and connect a FileMaker webviewer.`; + + let response: Response; + try { + response = await fetch(connectedFilesUrl, { + signal: controller.signal, + }); + } catch (error) { + throw new Error(reachabilityErrorMessage, { + cause: error, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + throw new Error( + `fmBridge received HTTP ${response.status} from ${connectedFilesUrl}. Ensure fm-http is healthy and reachable.`, + ); + } + + const payload = (await response.json()) as unknown; + if (!Array.isArray(payload)) { + throw new Error(`fmBridge expected an array response from ${connectedFilesUrl}.`); + } + + const firstFileName = payload.find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); + + if (!firstFileName) { + throw new Error( + `fmBridge found no connected FileMaker files at ${connectedFilesUrl}. Open FileMaker and load /webviewer?fileName=YourFile.`, + ); + } + + return firstFileName; +}; + +export const buildMockScriptTag = (options: { + baseUrl: string; + fileName: string | null; + wsUrl: string; + debug: boolean; +}): HtmlTagDescriptor | null => { + if (!options.fileName) { + return null; + } + + const scriptUrl = new URL(`${normalizeBaseUrl(options.baseUrl)}/fm-mock.js`); + scriptUrl.searchParams.set("fileName", options.fileName); + scriptUrl.searchParams.set("wsUrl", options.wsUrl); + + if (options.debug) { + scriptUrl.searchParams.set("debug", "true"); + } + + return { + tag: "script", + attrs: { src: scriptUrl.toString() }, + injectTo: "head-prepend", + }; +}; + +export const fmBridge = (options: FmBridgeOptions = {}): Plugin => { + const baseUrl = trimToNull(options.fmHttpBaseUrl) ?? defaultFmHttpBaseUrl; + const wsUrl = resolveWsUrl(options); + const debug = options.debug === true; + let resolvedFileName: string | null = trimToNull(options.fileName); + let isServeMode = true; + + return { + name: "proofkit-fm-bridge", + apply(_config, { command }) { + isServeMode = command === "serve"; + return isServeMode; + }, + async configureServer() { + if (!isServeMode) { + return; + } + + if (resolvedFileName) { + return; + } + + resolvedFileName = await discoverConnectedFileName(baseUrl); + }, + async transformIndexHtml() { + if (!isServeMode) { + return; + } + + if (!resolvedFileName) { + resolvedFileName = await discoverConnectedFileName(baseUrl); + } + + const tag = buildMockScriptTag({ + baseUrl, + fileName: resolvedFileName, + wsUrl, + debug, + }); + + return tag ? [tag] : undefined; + }, + }; +}; diff --git a/packages/webviewer/src/vite-plugins.ts b/packages/webviewer/src/vite-plugins.ts new file mode 100644 index 00000000..a57fade0 --- /dev/null +++ b/packages/webviewer/src/vite-plugins.ts @@ -0,0 +1,6 @@ +import type { FmBridgeOptions as InternalFmBridgeOptions } from "./fm-bridge.js"; +import { fmBridge as createFmBridge } from "./fm-bridge.js"; + +export interface FmBridgeOptions extends InternalFmBridgeOptions {} + +export const fmBridge = createFmBridge; diff --git a/packages/webviewer/tests/vite-plugins.test.ts b/packages/webviewer/tests/vite-plugins.test.ts new file mode 100644 index 00000000..eda68109 --- /dev/null +++ b/packages/webviewer/tests/vite-plugins.test.ts @@ -0,0 +1,203 @@ +import type { HtmlTagDescriptor } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + buildMockScriptTag, + defaultWsUrl, + discoverConnectedFileName, + fmBridge, + resolveWsUrl, +} from "../src/fm-bridge.ts"; + +describe("resolveWsUrl", () => { + it("prefers an explicit websocket URL", () => { + expect( + resolveWsUrl({ + fmHttpBaseUrl: "http://localhost:1365", + wsUrl: "ws://example.test/custom", + }), + ).toBe("ws://example.test/custom"); + }); + + it("derives the websocket URL from the HTTP base URL", () => { + expect( + resolveWsUrl({ + fmHttpBaseUrl: "https://example.test:9999/", + }), + ).toBe("wss://example.test:9999/ws"); + }); + + it("falls back to the default websocket URL for invalid base URLs", () => { + expect( + resolveWsUrl({ + fmHttpBaseUrl: "not a url", + }), + ).toBe(defaultWsUrl); + }); +}); + +describe("buildMockScriptTag", () => { + it("builds the fm-mock script tag with required query params", () => { + const tag = buildMockScriptTag({ + baseUrl: "http://localhost:1365/", + fileName: "Contacts", + wsUrl: "ws://localhost:1365/ws", + debug: true, + }); + + expect(tag).toEqual({ + tag: "script", + attrs: { + src: "http://localhost:1365/fm-mock.js?fileName=Contacts&wsUrl=ws%3A%2F%2Flocalhost%3A1365%2Fws&debug=true", + }, + injectTo: "head-prepend", + } satisfies HtmlTagDescriptor); + }); +}); + +describe("discoverConnectedFileName", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns the first non-empty connected file name", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify(["", "Contacts", "Invoices"]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(discoverConnectedFileName("http://localhost:1365")).resolves.toBe("Contacts"); + expect(globalThis.fetch).toHaveBeenCalledWith( + "http://localhost:1365/connectedFiles", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("aborts stalled requests with the standard reachability error", async () => { + vi.useFakeTimers(); + + try { + const abortError = new DOMException("The operation was aborted.", "AbortError"); + vi.mocked(globalThis.fetch).mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(abortError); + }); + }), + ); + + const pendingRequest = discoverConnectedFileName("http://localhost:1365"); + const pendingExpectation = expect(pendingRequest).rejects.toMatchObject({ + cause: abortError, + message: + "fmBridge could not reach http://localhost:1365/connectedFiles. Start fm-http and connect a FileMaker webviewer.", + }); + + await vi.advanceTimersByTimeAsync(5000); + await pendingExpectation; + } finally { + vi.useRealTimers(); + } + }); + + it("rejects non-ok HTTP responses", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(new Response("nope", { status: 503 })); + + await expect(discoverConnectedFileName("http://localhost:1365")).rejects.toThrow( + "fmBridge received HTTP 503 from http://localhost:1365/connectedFiles. Ensure fm-http is healthy and reachable.", + ); + }); + + it("rejects invalid payloads", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify({ fileName: "Contacts" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(discoverConnectedFileName("http://localhost:1365")).rejects.toThrow( + "fmBridge expected an array response from http://localhost:1365/connectedFiles.", + ); + }); + + it("rejects when no connected files are available", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify(["", " "]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(discoverConnectedFileName("http://localhost:1365")).rejects.toThrow( + "fmBridge found no connected FileMaker files at http://localhost:1365/connectedFiles. Open FileMaker and load /webviewer?fileName=YourFile.", + ); + }); +}); + +describe("fmBridge", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("injects the bridge script in serve mode", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify(["Contacts"]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const plugin = fmBridge({ + fmHttpBaseUrl: "http://localhost:1365", + debug: true, + }); + + if (typeof plugin.apply === "function") { + expect(plugin.apply({} as never, { command: "serve", mode: "development" } as never)).toBe(true); + } + + const tags = await plugin.transformIndexHtml?.(""); + + expect(tags).toEqual([ + { + tag: "script", + attrs: { + src: "http://localhost:1365/fm-mock.js?fileName=Contacts&wsUrl=ws%3A%2F%2Flocalhost%3A1365%2Fws&debug=true", + }, + injectTo: "head-prepend", + }, + ]); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("opts out of build mode", async () => { + const plugin = fmBridge({ + fmHttpBaseUrl: "http://localhost:1365", + }); + + expect(typeof plugin.apply).toBe("function"); + if (typeof plugin.apply !== "function") { + return; + } + + expect(plugin.apply({} as never, { command: "build", mode: "production" } as never)).toBe(false); + await expect(plugin.transformIndexHtml?.("")).resolves.toBeUndefined(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webviewer/vite.config.ts b/packages/webviewer/vite.config.ts index 1e0f3b51..8c1c1070 100644 --- a/packages/webviewer/vite.config.ts +++ b/packages/webviewer/vite.config.ts @@ -8,7 +8,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ["./src/main.ts", "./src/adapter.ts"], + entry: ["./src/main.ts", "./src/adapter.ts", "./src/vite-plugins.ts"], externalDeps: ["@proofkit/fmdapi"], srcDir: "./src", }), diff --git a/packages/webviewer/vitest.config.ts b/packages/webviewer/vitest.config.ts new file mode 100644 index 00000000..3ca675a8 --- /dev/null +++ b/packages/webviewer/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f9fb11..10b465f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: vite: specifier: ^6.4.1 version: 6.4.1(@types/node@22.19.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^4.0.17 + version: 4.0.17(@types/node@22.19.5)(happy-dom@20.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) packages: