Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fm-bridge-empty-connected-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/webviewer": patch
---

Soften the Vite FM bridge startup path when FM MCP responds but has no connected files. The dev server now logs a warning, injects a fallback bridge shim, and logs runtime errors if bridge calls are made before a file connects. Unreachable or unhealthy FM MCP still fails setup.
64 changes: 54 additions & 10 deletions packages/webviewer/src/fm-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export const trimToNull = (value: unknown): string | null => {

export const normalizeBaseUrl = (value: string): string => value.replace(TRAILING_SLASH_PATTERN, "");

export const buildNoConnectedFilesWarning = (connectedFilesUrl: string): string =>
`fmBridge found no connected FileMaker files at ${connectedFilesUrl}. Dev server will continue. Connect a FileMaker webviewer to enable bridge forwarding.`;

export const buildNoConnectedFilesRuntimeError = (connectedFilesUrl: string): string =>
`fmBridge could not forward message because no connected FileMaker file is available from ${connectedFilesUrl}.`;

export const resolveWsUrl = (options: Pick<FmBridgeOptions, "fmMcpBaseUrl" | "wsUrl">): string => {
const explicitWsUrl = trimToNull(options.wsUrl);
if (explicitWsUrl) {
Expand All @@ -41,7 +47,7 @@ export const resolveWsUrl = (options: Pick<FmBridgeOptions, "fmMcpBaseUrl" | "ws
}
};

export const discoverConnectedFileName = async (baseUrl: string): Promise<string> => {
export const discoverConnectedFileName = async (baseUrl: string): Promise<string | null> => {
const connectedFilesUrl = `${normalizeBaseUrl(baseUrl)}/connectedFiles`;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
Expand Down Expand Up @@ -76,9 +82,7 @@ export const discoverConnectedFileName = async (baseUrl: string): Promise<string
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 null;
}

return firstFileName;
Expand Down Expand Up @@ -109,6 +113,41 @@ export const buildMockScriptTag = (options: {
};
};

export const buildNoConnectedFilesScriptTag = (baseUrl: string): HtmlTagDescriptor => {
const connectedFilesUrl = `${normalizeBaseUrl(baseUrl)}/connectedFiles`;
const errorMessage = buildNoConnectedFilesRuntimeError(connectedFilesUrl);

return {
tag: "script",
injectTo: "head-prepend",
children: `
(() => {
const errorMessage = ${JSON.stringify(errorMessage)};
const report = () => {
console.error(errorMessage);
return undefined;
};

if (!window.filemaker) {
const filemakerStub = function filemaker() {
return report();
};
filemakerStub.performScript = report;
filemakerStub.performScriptWithOption = report;
window.filemaker = filemakerStub;
}

if (!window.FileMaker) {
window.FileMaker = {
PerformScript: report,
PerformScriptWithOption: report,
};
}
})();
`.trim(),
};
};

export const fmBridge = (options: FmBridgeOptions = {}): Plugin => {
const baseUrl = trimToNull(options.fmMcpBaseUrl) ?? defaultFmMcpBaseUrl;
const wsUrl = resolveWsUrl(options);
Expand All @@ -132,6 +171,9 @@ export const fmBridge = (options: FmBridgeOptions = {}): Plugin => {
}

resolvedFileName = await discoverConnectedFileName(baseUrl);
if (!resolvedFileName) {
console.warn(buildNoConnectedFilesWarning(`${normalizeBaseUrl(baseUrl)}/connectedFiles`));
}
},
async transformIndexHtml() {
if (!isServeMode) {
Expand All @@ -142,12 +184,14 @@ export const fmBridge = (options: FmBridgeOptions = {}): Plugin => {
resolvedFileName = await discoverConnectedFileName(baseUrl);
}

const tag = buildMockScriptTag({
baseUrl,
fileName: resolvedFileName,
wsUrl,
debug,
});
const tag = resolvedFileName
? buildMockScriptTag({
baseUrl,
fileName: resolvedFileName,
wsUrl,
debug,
})
: buildNoConnectedFilesScriptTag(baseUrl);

return tag ? [tag] : undefined;
},
Expand Down
9 changes: 9 additions & 0 deletions packages/webviewer/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
interface Window {
handleFmWVFetchCallback: (data: unknown, fetchId: string) => boolean;
FileMaker?: {
PerformScript: (name: string, parameter: string) => void;
PerformScriptWithOption: (name: string, parameter: string, option: "0" | "1" | "2" | "3" | "4" | "5") => void;
};
filemaker?: {
(...args: unknown[]): unknown;
performScript?: (...args: unknown[]) => unknown;
performScriptWithOption?: (...args: unknown[]) => unknown;
};
}
96 changes: 92 additions & 4 deletions packages/webviewer/tests/vite-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
buildMockScriptTag,
buildNoConnectedFilesRuntimeError,
defaultWsUrl,
discoverConnectedFileName,
fmBridge,
Expand Down Expand Up @@ -130,29 +131,39 @@ describe("discoverConnectedFileName", () => {
);
});

it("rejects when no connected files are available", async () => {
it("returns null 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.",
);
await expect(discoverConnectedFileName("http://localhost:1365")).resolves.toBeNull();
});
});

describe("fmBridge", () => {
const originalFetch = globalThis.fetch;
const originalConsoleWarn = console.warn;
const originalConsoleError = console.error;
const originalWindow = globalThis.window;

beforeEach(() => {
globalThis.fetch = vi.fn();
console.warn = vi.fn();
console.error = vi.fn();
});

afterEach(() => {
globalThis.fetch = originalFetch;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
if (typeof originalWindow === "undefined") {
Reflect.deleteProperty(globalThis, "window");
} else {
globalThis.window = originalWindow;
}
});

it("injects the bridge script in serve mode", async () => {
Expand Down Expand Up @@ -200,4 +211,81 @@ describe("fmBridge", () => {
await expect(plugin.transformIndexHtml?.("")).resolves.toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it("warns and injects a fallback bridge when FM MCP responds with no connected files", async () => {
vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

const plugin = fmBridge({
fmMcpBaseUrl: "http://localhost:1365",
});

if (typeof plugin.apply === "function") {
expect(plugin.apply({} as never, { command: "serve", mode: "development" } as never)).toBe(true);
}

await expect(plugin.configureServer?.({} as never)).resolves.toBeUndefined();
expect(console.warn).toHaveBeenCalledWith(
"fmBridge found no connected FileMaker files at http://localhost:1365/connectedFiles. Dev server will continue. Connect a FileMaker webviewer to enable bridge forwarding.",
);

const tags = await plugin.transformIndexHtml?.("");

expect(tags).toHaveLength(1);
expect(tags?.[0]).toMatchObject({
tag: "script",
injectTo: "head-prepend",
});
expect(tags?.[0]).toHaveProperty("children");
});

it("logs runtime errors from the fallback bridge when no file is connected", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

const plugin = fmBridge({
fmMcpBaseUrl: "http://localhost:1365",
});

if (typeof plugin.apply === "function") {
expect(plugin.apply({} as never, { command: "serve", mode: "development" } as never)).toBe(true);
}

const tags = await plugin.transformIndexHtml?.("");
const tag = tags?.[0];

expect(tag).toHaveProperty("children");

globalThis.window = {} as Window & typeof globalThis;
new Function((tag as HtmlTagDescriptor & { children: string }).children)();

expect(typeof globalThis.window.filemaker).toBe("function");
globalThis.window.filemaker?.("TestScript", "{}");
globalThis.window.FileMaker?.PerformScript("TestScript", "{}");

expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenNthCalledWith(
1,
buildNoConnectedFilesRuntimeError("http://localhost:1365/connectedFiles"),
);
expect(console.error).toHaveBeenNthCalledWith(
2,
buildNoConnectedFilesRuntimeError("http://localhost:1365/connectedFiles"),
);
});
});
Loading