From a00a480241172e52355dde99cf542d435e5385e9 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 19 May 2026 08:27:57 -0500 Subject: [PATCH 1/4] Add webviewer command registry exports --- .changeset/webviewer-commands.md | 7 + apps/docs/content/docs/webviewer/commands.mdx | 135 ++++++++ apps/docs/content/docs/webviewer/meta.json | 1 + packages/cli/template/vite-wv/src/main.tsx | 3 + packages/webviewer/package.json | 29 ++ packages/webviewer/src/commands.ts | 316 ++++++++++++++++++ packages/webviewer/src/react.ts | 25 ++ packages/webviewer/tests/commands.test.ts | 192 +++++++++++ packages/webviewer/vite.config.ts | 9 +- pnpm-lock.yaml | 3 + 10 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 .changeset/webviewer-commands.md create mode 100644 apps/docs/content/docs/webviewer/commands.mdx create mode 100644 packages/webviewer/src/commands.ts create mode 100644 packages/webviewer/src/react.ts create mode 100644 packages/webviewer/tests/commands.test.ts diff --git a/.changeset/webviewer-commands.md b/.changeset/webviewer-commands.md new file mode 100644 index 00000000..94397e9e --- /dev/null +++ b/.changeset/webviewer-commands.md @@ -0,0 +1,7 @@ +--- +"@proofkit/webviewer": minor +"@proofkit/docs": minor +"@proofkit/cli": minor +--- + +Add typed Web Viewer command registry. diff --git a/apps/docs/content/docs/webviewer/commands.mdx b/apps/docs/content/docs/webviewer/commands.mdx new file mode 100644 index 00000000..2b57f301 --- /dev/null +++ b/apps/docs/content/docs/webviewer/commands.mdx @@ -0,0 +1,135 @@ +--- +title: "FileMaker to JavaScript commands" +description: Register typed Web Viewer handlers for FileMaker script calls. +--- + +FileMaker can call a global Web Viewer namespace. Your app registers typed handlers on that namespace. + +## Initialize + +Call `initWebViewerCommands()` once in browser code. + +```ts +import { initWebViewerCommands } from "@proofkit/webviewer/commands"; + +initWebViewerCommands(); +``` + +The default namespace is `proofkit`, so FileMaker calls `proofkit.openCustomer`. + +Use a custom namespace when needed. + +```ts +initWebViewerCommands({ namespace: "__host__" }); +``` + +The module is import-safe on the server. `initWebViewerCommands()` returns `undefined` when `window` is unavailable. + +## Type commands + +Declare commands in a `.d.ts` file. + +```ts +export {}; + +declare module "@proofkit/webviewer/commands" { + interface WebViewerCommandRegistry { + openCustomer: (recordId: string) => void; + refreshDashboard: () => void; + } +} +``` + +FileMaker passes string parameters, so command parameters must be strings. + +## Static commands + +Use `registerWebViewerCommand` for handlers that do not depend on component state. + +```ts +import { registerWebViewerCommand } from "@proofkit/webviewer/commands"; + +registerWebViewerCommand("refreshDashboard", () => { + window.location.reload(); +}); +``` + +## React commands + +Use `useWebViewerCommand` when a handler depends on UI state, routing, or component data. + +```tsx +import { useWebViewerCommand } from "@proofkit/webviewer/react"; + +export function CustomerScreen() { + useWebViewerCommand("openCustomer", (recordId) => { + console.log(recordId); + }); + + return null; +} +``` + +The hook registers on mount, unregisters on unmount, and keeps the latest callback after rerenders. + +## Next.js + +Initialize from Client Components only. + +```tsx +"use client"; + +import { initWebViewerCommands } from "@proofkit/webviewer/commands"; +import { useEffect } from "react"; + +export function WebViewerCommandBootstrap() { + useEffect(() => { + initWebViewerCommands(); + }, []); + + return null; +} +``` + +Use import-time initialization only in client-only modules. + +```tsx +"use client"; + +import { initWebViewerCommands } from "@proofkit/webviewer/commands"; + +initWebViewerCommands(); +``` + +## Early calls + +Missing commands buffer by default. If FileMaker calls before React mounts, the call replays when the command registers. + +```ts +initWebViewerCommands({ + missingCommand: "buffer", + maxBufferedCallsPerCommand: 100, +}); +``` + +Other missing-command modes are `drop`, `warn`, and `throw`. + +## Migration + +Avoid direct `window.proofkit` assignment. It bypasses buffering, cleanup, and type checks. + +Before: + +```tsx +useEffect(() => { + window.proofkit = { + openDialog: () => setOpen(true), + }; +}, []); +``` + +After: + +```tsx +useWebViewerCommand("openDialog", () => setOpen(true)); +``` diff --git a/apps/docs/content/docs/webviewer/meta.json b/apps/docs/content/docs/webviewer/meta.json index 0294cdf0..62395967 100644 --- a/apps/docs/content/docs/webviewer/meta.json +++ b/apps/docs/content/docs/webviewer/meta.json @@ -17,6 +17,7 @@ "---Reference---", "fmFetch", "callFmScript", + "commands", "fm-bridge", "fmdapi", "troubleshooting" diff --git a/packages/cli/template/vite-wv/src/main.tsx b/packages/cli/template/vite-wv/src/main.tsx index 5a44156c..c292e345 100644 --- a/packages/cli/template/vite-wv/src/main.tsx +++ b/packages/cli/template/vite-wv/src/main.tsx @@ -1,11 +1,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; +import { initWebViewerCommands } from "@proofkit/webviewer/commands"; import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; import { router } from "./router"; +initWebViewerCommands(); + const queryClient = new QueryClient(); const rootElement = document.querySelector("#root"); diff --git a/packages/webviewer/package.json b/packages/webviewer/package.json index 75f83847..c8812438 100644 --- a/packages/webviewer/package.json +++ b/packages/webviewer/package.json @@ -51,6 +51,26 @@ "default": "./dist/cjs/nextjs.cjs" } }, + "./commands": { + "import": { + "types": "./dist/esm/commands.d.ts", + "default": "./dist/esm/commands.js" + }, + "require": { + "types": "./dist/cjs/commands.d.cts", + "default": "./dist/cjs/commands.cjs" + } + }, + "./react": { + "import": { + "types": "./dist/esm/react.d.ts", + "default": "./dist/esm/react.js" + }, + "require": { + "types": "./dist/cjs/react.d.cts", + "default": "./dist/cjs/react.cjs" + } + }, "./package.json": "./package.json" }, "dependencies": { @@ -60,6 +80,14 @@ "next": ">=13.0.0", "react": ">=18.0.0" }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + } + }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@proofkit/fmdapi": "workspace:*", @@ -67,6 +95,7 @@ "@tanstack/vite-config": "^0.2.1", "@types/filemaker-webviewer": "^1.0.3", "@types/node": "^22.19.5", + "@types/react": "19.2.7", "@types/uuid": "^10.0.0", "knip": "^5.80.2", "publint": "^0.3.16", diff --git a/packages/webviewer/src/commands.ts b/packages/webviewer/src/commands.ts new file mode 100644 index 00000000..ddb8c14a --- /dev/null +++ b/packages/webviewer/src/commands.ts @@ -0,0 +1,316 @@ +declare const webViewerCommandRegistryBrand: unique symbol; + +export interface WebViewerCommandRegistry { + readonly [webViewerCommandRegistryBrand]?: never; +} + +export type AnyWebViewerCommand = (...args: string[]) => unknown; + +export type WebViewerCommandName = keyof WebViewerCommandRegistry & string; + +export type WebViewerCommand = WebViewerCommandRegistry[K] extends AnyWebViewerCommand + ? WebViewerCommandRegistry[K] + : never; + +export interface WebViewerCommandRegistryOptions { + namespace?: string; + missingCommand?: "buffer" | "drop" | "warn" | "throw"; + maxBufferedCallsPerCommand?: number; + allowDirectAssignment?: boolean; + onError?: ( + error: unknown, + context: { + name: string; + phase: "call" | "replay"; + }, + ) => void; + onWarn?: (message: string, context: { name?: string }) => void; +} + +export interface WebViewerCommandRegistryHandle { + namespace: string; + target: Record; + clearPendingCalls: (name?: string) => void; + listRegisteredCommands: () => string[]; + listPendingCommands: () => string[]; +} + +interface WebViewerCommandState { + namespace: string; + handlers: Map; + handlerWrappers: Map; + pendingCalls: Map; + options: Required< + Pick + > & + Pick; + target?: Record; + handle?: WebViewerCommandRegistryHandle; +} + +const DEFAULT_NAMESPACE = "proofkit"; +const DEFAULT_MAX_BUFFERED_CALLS = 100; +const targetState = new WeakMap(); +const states = new Map(); + +let activeNamespace = DEFAULT_NAMESPACE; + +const getWindow = (): (Window & typeof globalThis) | undefined => (typeof window === "undefined" ? undefined : window); + +const warn = (state: WebViewerCommandState, message: string, context: { name?: string } = {}) => { + if (state.options.onWarn) { + state.options.onWarn(message, context); + return; + } + console.warn(message); +}; + +const reportError = ( + state: WebViewerCommandState, + error: unknown, + context: { + name: string; + phase: "call" | "replay"; + }, +) => { + if (state.options.onError) { + state.options.onError(error, context); + return; + } + console.error(error); +}; + +const getState = (namespace: string): WebViewerCommandState => { + const existing = states.get(namespace); + if (existing) { + return existing; + } + + const state: WebViewerCommandState = { + namespace, + handlers: new Map(), + handlerWrappers: new Map(), + pendingCalls: new Map(), + options: { + missingCommand: "buffer", + maxBufferedCallsPerCommand: DEFAULT_MAX_BUFFERED_CALLS, + allowDirectAssignment: false, + }, + }; + states.set(namespace, state); + return state; +}; + +const applyOptions = (state: WebViewerCommandState, options: WebViewerCommandRegistryOptions = {}) => { + state.options = { + ...state.options, + ...options, + maxBufferedCallsPerCommand: options.maxBufferedCallsPerCommand ?? state.options.maxBufferedCallsPerCommand, + missingCommand: options.missingCommand ?? state.options.missingCommand, + allowDirectAssignment: options.allowDirectAssignment ?? state.options.allowDirectAssignment, + }; +}; + +const bufferCall = (state: WebViewerCommandState, name: string, args: string[]) => { + const calls = state.pendingCalls.get(name) ?? []; + calls.push(args); + + if (calls.length > state.options.maxBufferedCallsPerCommand) { + calls.shift(); + warn(state, `[webviewer commands] dropping oldest buffered call for "${name}"`, { name }); + } + + state.pendingCalls.set(name, calls); +}; + +const getMissingCommand = (state: WebViewerCommandState, name: string): AnyWebViewerCommand => { + switch (state.options.missingCommand) { + case "drop": + return () => undefined; + case "warn": + return () => { + warn(state, `[webviewer commands] missing command "${name}"`, { name }); + }; + case "throw": + return () => { + throw new Error(`[webviewer commands] missing command "${name}"`); + }; + case "buffer": + return (...args: string[]) => { + bufferCall(state, name, args); + }; + default: + return () => undefined; + } +}; + +const getCommandWrapper = (state: WebViewerCommandState, name: string, handler: AnyWebViewerCommand) => { + const existing = state.handlerWrappers.get(name); + if (existing) { + return existing; + } + + const wrappedHandler: AnyWebViewerCommand = (...args) => { + try { + return handler(...args); + } catch (error) { + reportError(state, error, { name, phase: "call" }); + return undefined; + } + }; + state.handlerWrappers.set(name, wrappedHandler); + return wrappedHandler; +}; + +const makeHandle = (state: WebViewerCommandState): WebViewerCommandRegistryHandle => ({ + namespace: state.namespace, + target: state.target ?? {}, + clearPendingCalls: (name?: string) => { + if (name) { + state.pendingCalls.delete(name); + return; + } + state.pendingCalls.clear(); + }, + listRegisteredCommands: () => [...state.handlers.keys()], + listPendingCommands: () => [...state.pendingCalls.keys()], +}); + +const createProxy = (state: WebViewerCommandState): Record => + new Proxy>( + {}, + { + get: (_target, property) => { + if (typeof property !== "string") { + return undefined; + } + + const handler = state.handlers.get(property); + if (handler) { + return getCommandWrapper(state, property, handler); + } + + return getMissingCommand(state, property); + }, + has: (_target, property) => typeof property === "string" && state.handlers.has(property), + ownKeys: () => [...state.handlers.keys()], + getOwnPropertyDescriptor: (_target, property) => { + if (typeof property !== "string" || !state.handlers.has(property)) { + return undefined; + } + + return { + configurable: true, + enumerable: true, + value: getCommandWrapper(state, property, state.handlers.get(property) as AnyWebViewerCommand), + }; + }, + set: (_target, property, value) => { + if (typeof property !== "string") { + return true; + } + + if (!state.options.allowDirectAssignment) { + warn(state, `[webviewer commands] direct assignment unsupported for "${property}"`, { name: property }); + return true; + } + + if (typeof value !== "function") { + warn(state, `[webviewer commands] direct assignment for "${property}" must be a function`, { + name: property, + }); + return true; + } + + registerCommand(state, property, value as AnyWebViewerCommand); + return true; + }, + }, + ); + +const registerCommand = (state: WebViewerCommandState, name: string, fn: AnyWebViewerCommand) => { + if (state.handlers.has(name)) { + warn(state, `[webviewer commands] overwriting command "${name}"`, { name }); + } + + state.handlers.set(name, fn); + state.handlerWrappers.delete(name); + + const pending = state.pendingCalls.get(name); + if (!pending) { + return; + } + + state.pendingCalls.delete(name); + for (const args of pending) { + try { + fn(...args); + } catch (error) { + reportError(state, error, { name, phase: "replay" }); + } + } +}; + +export const initWebViewerCommands = ( + options: WebViewerCommandRegistryOptions = {}, +): WebViewerCommandRegistryHandle | undefined => { + const namespace = options.namespace ?? activeNamespace; + activeNamespace = namespace; + + const state = getState(namespace); + applyOptions(state, options); + + const browserWindow = getWindow(); + if (!browserWindow) { + return undefined; + } + + const globalTarget = browserWindow as unknown as Record; + const existing = globalTarget[namespace]; + + if (existing !== undefined && existing !== null) { + const existingState = targetState.get(existing); + if (existingState) { + applyOptions(existingState, options); + existingState.handle ??= makeHandle(existingState); + return existingState.handle; + } + + warn(state, `[webviewer commands] window.${namespace} already exists; preserving existing value`); + return undefined; + } + + const target = createProxy(state); + state.target = target; + targetState.set(target, state); + globalTarget[namespace] = target; + state.handle = makeHandle(state); + + return state.handle; +}; + +export const registerWebViewerCommand = ( + name: K, + fn: WebViewerCommand, +): (() => void) => { + initWebViewerCommands(); + const state = getState(activeNamespace); + const handler = fn as AnyWebViewerCommand; + registerCommand(state, name, handler); + + return () => { + if (state.handlers.get(name) === handler) { + state.handlers.delete(name); + state.handlerWrappers.delete(name); + } + }; +}; + +export const unregisterWebViewerCommand = (name: WebViewerCommandName) => { + const state = getState(activeNamespace); + state.handlers.delete(name); + state.handlerWrappers.delete(name); +}; + +export const getWebViewerCommandRegistry = (): WebViewerCommandRegistryHandle | undefined => + getState(activeNamespace).handle; diff --git a/packages/webviewer/src/react.ts b/packages/webviewer/src/react.ts new file mode 100644 index 00000000..8f544836 --- /dev/null +++ b/packages/webviewer/src/react.ts @@ -0,0 +1,25 @@ +import React from "react"; + +import { + type AnyWebViewerCommand, + initWebViewerCommands, + registerWebViewerCommand, + type WebViewerCommand, + type WebViewerCommandName, +} from "./commands.js"; + +const ReactHooks = React as unknown as { + useEffect: (effect: () => undefined | (() => void), deps: readonly unknown[]) => void; + useRef: (initialValue: T) => { current: T }; +}; + +export const useWebViewerCommand = (name: K, fn: WebViewerCommand) => { + const latestFn = ReactHooks.useRef(fn); + latestFn.current = fn; + + ReactHooks.useEffect(() => { + initWebViewerCommands(); + return registerWebViewerCommand(name, ((...args: string[]) => + (latestFn.current as AnyWebViewerCommand)(...args)) as WebViewerCommand); + }, [name]); +}; diff --git a/packages/webviewer/tests/commands.test.ts b/packages/webviewer/tests/commands.test.ts new file mode 100644 index 00000000..ea8f21fd --- /dev/null +++ b/packages/webviewer/tests/commands.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getWebViewerCommandRegistry, + initWebViewerCommands, + registerWebViewerCommand, + unregisterWebViewerCommand, +} from "../src/commands.ts"; + +declare module "../src/commands.ts" { + interface WebViewerCommandRegistry { + ping: (value: string) => void; + fail: (value: string) => void; + noop: () => void; + } +} + +describe("web viewer commands", () => { + const originalWindow = globalThis.window; + const originalConsoleWarn = console.warn; + const originalConsoleError = console.error; + + beforeEach(() => { + globalThis.window = {} as Window & typeof globalThis; + console.warn = vi.fn(); + console.error = vi.fn(); + }); + + afterEach(() => { + if (typeof originalWindow === "undefined") { + Reflect.deleteProperty(globalThis, "window"); + } else { + globalThis.window = originalWindow; + } + + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + it("returns undefined without window", () => { + Reflect.deleteProperty(globalThis, "window"); + + expect(initWebViewerCommands({ namespace: "noWindow" })).toBeUndefined(); + }); + + it("creates window.proofkit by default", () => { + const registry = initWebViewerCommands({ namespace: "proofkit" }); + + expect(registry?.namespace).toBe("proofkit"); + expect(globalThis.window.proofkit).toBe(registry?.target); + }); + + it("buffers missing calls and replays on registration", () => { + initWebViewerCommands({ namespace: "buffered" }); + const handler = vi.fn(); + + globalThis.window.buffered.ping("first"); + globalThis.window.buffered.ping("second"); + const cleanup = registerWebViewerCommand("ping", handler); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith(1, "first"); + expect(handler).toHaveBeenNthCalledWith(2, "second"); + expect(getWebViewerCommandRegistry()?.listPendingCommands()).toEqual([]); + + cleanup(); + }); + + it("deletes queue before replay and continues after errors", () => { + const errors: unknown[] = []; + initWebViewerCommands({ + namespace: "replay", + onError: (error) => errors.push(error), + }); + globalThis.window.replay.fail("bad"); + globalThis.window.replay.ping("ok"); + + registerWebViewerCommand("fail", () => { + throw new Error("boom"); + }); + const handler = vi.fn(); + registerWebViewerCommand("ping", handler); + + expect(errors).toHaveLength(1); + expect(handler).toHaveBeenCalledWith("ok"); + expect(getWebViewerCommandRegistry()?.listPendingCommands()).toEqual([]); + }); + + it("reports registered command call errors", () => { + const errors: unknown[] = []; + initWebViewerCommands({ + namespace: "callErrors", + onError: (error, context) => errors.push({ error, context }), + }); + registerWebViewerCommand("fail", () => { + throw new Error("boom"); + }); + + expect(() => globalThis.window.callErrors.fail("bad")).not.toThrow(); + expect(errors).toMatchObject([ + { + context: { + name: "fail", + phase: "call", + }, + }, + ]); + }); + + it("drops oldest buffered calls past max", () => { + initWebViewerCommands({ namespace: "bounded", maxBufferedCallsPerCommand: 2 }); + const handler = vi.fn(); + + globalThis.window.bounded.ping("one"); + globalThis.window.bounded.ping("two"); + globalThis.window.bounded.ping("three"); + registerWebViewerCommand("ping", handler); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith(1, "two"); + expect(handler).toHaveBeenNthCalledWith(2, "three"); + expect(console.warn).toHaveBeenCalledWith('[webviewer commands] dropping oldest buffered call for "ping"'); + }); + + it("supports drop, warn, and throw missing command modes", () => { + initWebViewerCommands({ namespace: "dropper", missingCommand: "drop" }); + globalThis.window.dropper.ping("drop"); + + initWebViewerCommands({ namespace: "warner", missingCommand: "warn" }); + globalThis.window.warner.ping("warn"); + + initWebViewerCommands({ namespace: "thrower", missingCommand: "throw" }); + + expect(() => globalThis.window.thrower.ping("throw")).toThrow('[webviewer commands] missing command "ping"'); + expect(console.warn).toHaveBeenCalledWith('[webviewer commands] missing command "ping"'); + }); + + it("cleanup only removes the same handler", () => { + initWebViewerCommands({ namespace: "cleanup" }); + const first = vi.fn(); + const second = vi.fn(); + + const firstCleanup = registerWebViewerCommand("ping", first); + const secondCleanup = registerWebViewerCommand("ping", second); + firstCleanup(); + globalThis.window.cleanup.ping("value"); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith("value"); + + secondCleanup(); + expect("ping" in globalThis.window.cleanup).toBe(false); + }); + + it("lists registered commands through proxy traps", () => { + initWebViewerCommands({ namespace: "listed" }); + const cleanup = registerWebViewerCommand("noop", () => undefined); + + expect(Object.keys(globalThis.window.listed)).toEqual(["noop"]); + expect("noop" in globalThis.window.listed).toBe(true); + expect(getWebViewerCommandRegistry()?.listRegisteredCommands()).toEqual(["noop"]); + + cleanup(); + }); + + it("custom namespace init is idempotent", () => { + const first = initWebViewerCommands({ namespace: "custom" }); + const second = initWebViewerCommands({ namespace: "custom" }); + + expect(second).toBe(first); + expect(globalThis.window.custom).toBe(first?.target); + }); + + it("preserves existing namespaces", () => { + globalThis.window.occupied = { existing: true }; + + expect(initWebViewerCommands({ namespace: "occupied" })).toBeUndefined(); + expect(globalThis.window.occupied).toEqual({ existing: true }); + expect(console.warn).toHaveBeenCalledWith( + "[webviewer commands] window.occupied already exists; preserving existing value", + ); + }); + + it("unregisters active handlers", () => { + initWebViewerCommands({ namespace: "unregister" }); + registerWebViewerCommand("noop", () => undefined); + + unregisterWebViewerCommand("noop"); + + expect("noop" in globalThis.window.unregister).toBe(false); + }); +}); diff --git a/packages/webviewer/vite.config.ts b/packages/webviewer/vite.config.ts index 8eb85ce4..ff577c47 100644 --- a/packages/webviewer/vite.config.ts +++ b/packages/webviewer/vite.config.ts @@ -8,7 +8,14 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ["./src/main.ts", "./src/adapter.ts", "./src/vite-plugins.ts", "./src/nextjs.ts"], + entry: [ + "./src/main.ts", + "./src/adapter.ts", + "./src/vite-plugins.ts", + "./src/nextjs.ts", + "./src/commands.ts", + "./src/react.ts", + ], externalDeps: ["@proofkit/fmdapi", "next/script", "react"], srcDir: "./src", }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2bb66dd..9ac378ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -916,6 +916,9 @@ importers: '@types/node': specifier: ^22.19.5 version: 22.19.5 + '@types/react': + specifier: 19.2.7 + version: 19.2.7 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 From efca8377b5ccc84f12bee9dfc75fe2123e540d18 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 19 May 2026 08:44:07 -0500 Subject: [PATCH 2/4] Update proofkit worktree instructions --- apps/docs/content/docs/webviewer/commands.mdx | 33 +++++- packages/webviewer/src/commands.ts | 8 +- .../webviewer/tests/commands-types.test.ts | 105 ++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 packages/webviewer/tests/commands-types.test.ts diff --git a/apps/docs/content/docs/webviewer/commands.mdx b/apps/docs/content/docs/webviewer/commands.mdx index 2b57f301..2fbafc18 100644 --- a/apps/docs/content/docs/webviewer/commands.mdx +++ b/apps/docs/content/docs/webviewer/commands.mdx @@ -27,30 +27,51 @@ The module is import-safe on the server. `initWebViewerCommands()` returns `unde ## Type commands -Declare commands in a `.d.ts` file. +Declare commands in a `.d.ts` file. Extending `DefineWebViewerCommandRegistry` makes TypeScript check that every command is a function that accepts only string parameters. ```ts +import type { DefineWebViewerCommandRegistry } from "@proofkit/webviewer/commands"; + export {}; declare module "@proofkit/webviewer/commands" { - interface WebViewerCommandRegistry { + interface WebViewerCommandRegistry + extends DefineWebViewerCommandRegistry<{ openCustomer: (recordId: string) => void; refreshDashboard: () => void; - } + }> {} } ``` FileMaker passes string parameters, so command parameters must be strings. +You can also wrap individual entries with `WebViewerCommandHandler`. + +```ts +import type { WebViewerCommandHandler } from "@proofkit/webviewer/commands"; + +declare module "@proofkit/webviewer/commands" { + interface WebViewerCommandRegistry { + openCustomer: WebViewerCommandHandler<(recordId: string) => void>; + } +} +``` + ## Static commands -Use `registerWebViewerCommand` for handlers that do not depend on component state. +Use `registerWebViewerCommand` for handlers that do not depend on component state. This example lets FileMaker ask the Web Viewer to load a customer snapshot through `fmFetch`. ```ts +import { fmFetch } from "@proofkit/webviewer"; import { registerWebViewerCommand } from "@proofkit/webviewer/commands"; -registerWebViewerCommand("refreshDashboard", () => { - window.location.reload(); +registerWebViewerCommand("loadCustomerSummary", async (recordId) => { + const summary = await fmFetch("Load Customer Summary", { recordId }); + window.dispatchEvent( + new CustomEvent("customer-summary-loaded", { + detail: summary, + }) + ); }); ``` diff --git a/packages/webviewer/src/commands.ts b/packages/webviewer/src/commands.ts index ddb8c14a..f9a8c2ce 100644 --- a/packages/webviewer/src/commands.ts +++ b/packages/webviewer/src/commands.ts @@ -6,7 +6,13 @@ export interface WebViewerCommandRegistry { export type AnyWebViewerCommand = (...args: string[]) => unknown; -export type WebViewerCommandName = keyof WebViewerCommandRegistry & string; +export type WebViewerCommandHandler = T; + +export type DefineWebViewerCommandRegistry> = T; + +export type WebViewerCommandName = { + [K in keyof WebViewerCommandRegistry & string]: WebViewerCommandRegistry[K] extends AnyWebViewerCommand ? K : never; +}[keyof WebViewerCommandRegistry & string]; export type WebViewerCommand = WebViewerCommandRegistry[K] extends AnyWebViewerCommand ? WebViewerCommandRegistry[K] diff --git a/packages/webviewer/tests/commands-types.test.ts b/packages/webviewer/tests/commands-types.test.ts new file mode 100644 index 00000000..7158457e --- /dev/null +++ b/packages/webviewer/tests/commands-types.test.ts @@ -0,0 +1,105 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const runTypeScript = (source: string) => { + const dir = mkdtempSync(join(tmpdir(), "proofkit-webviewer-types-")); + const configPath = join(dir, "tsconfig.json"); + const sourcePath = join(dir, "index.ts"); + const commandsPath = resolve(import.meta.dirname, "../src/commands.ts"); + + writeFileSync( + configPath, + JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + target: "ESNext", + module: "NodeNext", + moduleResolution: "NodeNext", + lib: ["DOM", "ESNext"], + baseUrl: ".", + paths: { + "@proofkit/webviewer/commands": [commandsPath], + }, + }, + include: [sourcePath], + }, + null, + 2, + ), + ); + writeFileSync(sourcePath, source); + + execFileSync("pnpm", ["exec", "tsc", "--project", configPath], { + cwd: join(import.meta.dirname, "../../.."), + stdio: "pipe", + }); +}; + +describe("web viewer command types", () => { + it("allows valid string-parameter registry declarations", () => { + expect(() => + runTypeScript(` + import { + type DefineWebViewerCommandRegistry, + initWebViewerCommands, + registerWebViewerCommand, + } from "@proofkit/webviewer/commands"; + + declare module "@proofkit/webviewer/commands" { + interface WebViewerCommandRegistry + extends DefineWebViewerCommandRegistry<{ + openCustomer: (recordId: string) => void; + refreshDashboard: () => void; + }> {} + } + + initWebViewerCommands(); + registerWebViewerCommand("openCustomer", (recordId) => { + recordId.toUpperCase(); + }); + `), + ).not.toThrow(); + }); + + it("rejects invalid registry declarations through the helper type", () => { + expect(() => + runTypeScript(` + import { type DefineWebViewerCommandRegistry } from "@proofkit/webviewer/commands"; + + declare module "@proofkit/webviewer/commands" { + interface WebViewerCommandRegistry + extends DefineWebViewerCommandRegistry<{ + openCustomer: (recordId: string) => void; + badParameter: (recordId: number) => void; + badValue: string; + }> {} + } + `), + ).toThrow(); + }); + + it("rejects unknown commands and wrong handler signatures", () => { + expect(() => + runTypeScript(` + import { registerWebViewerCommand } from "@proofkit/webviewer/commands"; + + declare module "@proofkit/webviewer/commands" { + interface WebViewerCommandRegistry { + openCustomer: (recordId: string) => void; + } + } + + // @ts-expect-error unknown command name + registerWebViewerCommand("refreshDashboard", () => {}); + + // @ts-expect-error command requires a string parameter + registerWebViewerCommand("openCustomer", (recordId: number) => {}); + `), + ).not.toThrow(); + }); +}); From 691ae3f785a09ac2a78594a1c9730681b236f1e0 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 19 May 2026 12:30:47 -0500 Subject: [PATCH 3/4] update docs --- apps/docs/content/docs/webviewer/commands.mdx | 12 ++---------- apps/docs/content/docs/webviewer/meta.json | 2 +- packages/cli/template/vite-wv/src/main.tsx | 3 --- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/docs/content/docs/webviewer/commands.mdx b/apps/docs/content/docs/webviewer/commands.mdx index 2fbafc18..be78f8e9 100644 --- a/apps/docs/content/docs/webviewer/commands.mdx +++ b/apps/docs/content/docs/webviewer/commands.mdx @@ -1,6 +1,6 @@ --- -title: "FileMaker to JavaScript commands" -description: Register typed Web Viewer handlers for FileMaker script calls. +title: "Run JS from FileMaker" +description: How to use the Perform JavaScript in Web Viewer script step to trigger functions inside your web viewer app. --- FileMaker can call a global Web Viewer namespace. Your app registers typed handlers on that namespace. @@ -15,14 +15,6 @@ import { initWebViewerCommands } from "@proofkit/webviewer/commands"; initWebViewerCommands(); ``` -The default namespace is `proofkit`, so FileMaker calls `proofkit.openCustomer`. - -Use a custom namespace when needed. - -```ts -initWebViewerCommands({ namespace: "__host__" }); -``` - The module is import-safe on the server. `initWebViewerCommands()` returns `undefined` when `window` is unavailable. ## Type commands diff --git a/apps/docs/content/docs/webviewer/meta.json b/apps/docs/content/docs/webviewer/meta.json index 62395967..a7c9b328 100644 --- a/apps/docs/content/docs/webviewer/meta.json +++ b/apps/docs/content/docs/webviewer/meta.json @@ -12,12 +12,12 @@ "runtime-under-the-hood", "data-access", "filemaker-scripts-as-backend", + "commands", "platform-notes", "deployment-methods", "---Reference---", "fmFetch", "callFmScript", - "commands", "fm-bridge", "fmdapi", "troubleshooting" diff --git a/packages/cli/template/vite-wv/src/main.tsx b/packages/cli/template/vite-wv/src/main.tsx index c292e345..5a44156c 100644 --- a/packages/cli/template/vite-wv/src/main.tsx +++ b/packages/cli/template/vite-wv/src/main.tsx @@ -1,14 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { initWebViewerCommands } from "@proofkit/webviewer/commands"; import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; import { router } from "./router"; -initWebViewerCommands(); - const queryClient = new QueryClient(); const rootElement = document.querySelector("#root"); From 6479740d53cc93b18f1743a63ce23001866b5934 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 19 May 2026 12:36:22 -0500 Subject: [PATCH 4/4] Update changeset for Web Viewer command registry Removed '@proofkit/cli' from the changeset and added typed Web Viewer command registry. --- .changeset/webviewer-commands.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/webviewer-commands.md b/.changeset/webviewer-commands.md index 94397e9e..51c42aa7 100644 --- a/.changeset/webviewer-commands.md +++ b/.changeset/webviewer-commands.md @@ -1,7 +1,6 @@ --- "@proofkit/webviewer": minor "@proofkit/docs": minor -"@proofkit/cli": minor --- Add typed Web Viewer command registry.