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
2 changes: 1 addition & 1 deletion packages/typegen/skills/getting-started/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
14 changes: 13 additions & 1 deletion packages/webviewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
155 changes: 155 additions & 0 deletions packages/webviewer/src/fm-bridge.ts
Original file line number Diff line number Diff line change
@@ -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<FmBridgeOptions, "fmHttpBaseUrl" | "wsUrl">): 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<string> => {
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}.`);
}
Comment on lines +71 to +74
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle invalid JSON payloads with a descriptive error.

If /connectedFiles returns non-JSON, response.json() throws a generic parse error and loses fmBridge context.

Proposed fix
-  const payload = (await response.json()) as unknown;
+  let payload: unknown;
+  try {
+    payload = await response.json();
+  } catch (error) {
+    throw new Error(
+      `fmBridge received invalid JSON from ${connectedFilesUrl}. Ensure fm-http returned a JSON array.`,
+      { cause: error },
+    );
+  }

As per coding guidelines, "Handle errors appropriately in async code with try-catch blocks".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
throw new Error(`fmBridge expected an array response from ${connectedFilesUrl}.`);
}
let payload: unknown;
try {
payload = await response.json();
} catch (error) {
throw new Error(
`fmBridge received invalid JSON from ${connectedFilesUrl}. Ensure fm-http returned a JSON array.`,
{ cause: error },
);
}
if (!Array.isArray(payload)) {
throw new Error(`fmBridge expected an array response from ${connectedFilesUrl}.`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webviewer/src/fm-bridge.ts` around lines 71 - 74, The call to
response.json() inside fmBridge can throw a generic parse error and lose context
when /connectedFiles returns non-JSON; wrap the JSON parsing in a try-catch
around the await response.json() (referencing connectedFilesUrl, response, and
payload) and on parse failure throw a new Error that includes fmBridge context,
the connectedFilesUrl, and the original error message (optionally include the
raw response body via response.text() to aid debugging) before continuing with
the existing Array.isArray(payload) check.


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;
},
};
};
6 changes: 6 additions & 0 deletions packages/webviewer/src/vite-plugins.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading