From 9f31c71b78d48fbf1e7992e582f5e974577bf8d1 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 18:37:41 +0200 Subject: [PATCH 01/10] feat: batchSetFieldValue fills several fields in one call, all or none --- .changeset/batch-set-field-value.md | 11 + embed/README.md | 2 +- embed/embed-api.json | 234 +++++++- embed/etc/index.api.md | 29 +- embed/etc/protocol.api.md | 16 +- embed/etc/schemas.api.md | 11 + embed/etc/tanstack-ai.api.md | 2 +- embed/etc/tools.api.md | 9 + embed/scripts/check-bundle-size.mjs | 108 ++-- embed/scripts/generate.mjs | 771 +++++++++++++++--------- embed/src/bridge.ts | 609 +++++++++++-------- embed/src/generated/contract.ts | 20 +- embed/src/generated/drift.ts | 1 + embed/src/generated/method-names.ts | 2 +- embed/src/generated/schemas.ts | 7 + embed/src/generated/tools.ts | 1 + embed/src/generated/webmcp-tools.ts | 1 + embed/src/tools.ts | 135 +++-- embed/src/types.ts | 126 ++-- embed/src/webmcp.ts | 197 +++--- embed/test/bridge.test.ts | 794 ++++++++++++++----------- embed/test/case-transform.test.ts | 127 ++-- embed/test/helpers.ts | 15 +- embed/test/mount.test.ts | 721 +++++++++++++--------- embed/test/tanstack-ai.test.ts | 85 +-- embed/test/tools.test.ts | 98 +-- embed/test/unwrap.test.ts | 40 +- embed/test/webmcp.test.ts | 892 +++++++++++++++++----------- react/README.md | 1 + react/src/embed-pdf.tsx | 1 + 30 files changed, 3103 insertions(+), 1963 deletions(-) create mode 100644 .changeset/batch-set-field-value.md diff --git a/.changeset/batch-set-field-value.md b/.changeset/batch-set-field-value.md new file mode 100644 index 00000000..a78d7603 --- /dev/null +++ b/.changeset/batch-set-field-value.md @@ -0,0 +1,11 @@ +--- +"@simplepdf/embed": minor +"@simplepdf/react-embed-pdf": minor +--- + +**Fill several fields in one call: `batchSetFieldValue` sets every value at once, or none of them.** + +- `batchSetFieldValue({ updates: [{ fieldId, value }] })` applies every update in one step, reverted by a single undo in the editor, and resolves with no data. +- If any update is rejected (an unknown field, an invalid option, a read-only field, an unreadable signature URL, the same field twice), nothing changes: the result fails with `bad_request:batch_validation_failed` and `error.details.failures` lists every rejected update with its `fieldId`, `code` and `message` (typed as `BatchValidationFailedDetails`). +- Values follow the `setFieldValue` rules and are never typed out. +- Available everywhere the editor operations are: `embed.actions.batchSetFieldValue`, `useEmbed().actions.batchSetFieldValue`, the `batchSetFieldValue` agentic tool on every tool subpath, and the `simplepdf_embed_batch_set_field_value` WebMCP tool. diff --git a/embed/README.md b/embed/README.md index 4787cbf9..61c3270f 100644 --- a/embed/README.md +++ b/embed/README.md @@ -184,7 +184,7 @@ await embed.actions.rotatePage({ page: 1 }) await embed.actions.download() ``` -Full set: `createField`, `deleteFields`, `deletePages`, `detectFields`, `download`, `focusField`, `getAnnotatedPage`, `getDocumentContent`, `getFields`, `goTo`, `loadDocument`, `movePage`, `rotatePage`, `selectTool`, `setFieldValue`, `submit`. +Full set: `batchSetFieldValue`, `createField`, `deleteFields`, `deletePages`, `detectFields`, `download`, `focusField`, `getAnnotatedPage`, `getDocumentContent`, `getFields`, `goTo`, `loadDocument`, `movePage`, `rotatePage`, `selectTool`, `setFieldValue`, `submit`. **"Fill and read this document for me"** is just these operations in sequence, exactly what the agentic tools expose to a model: diff --git a/embed/embed-api.json b/embed/embed-api.json index 15ea6a49..acec3107 100644 --- a/embed/embed-api.json +++ b/embed/embed-api.json @@ -1,5 +1,5 @@ { - "editor_version": "65235d76-20261009T151312Z", + "editor_version": null, "description": "This is the SimplePDF editor interface contract. Drive the editor programmatically over window.postMessage with the editor iframe: post a JSON string { \"type\": {{operation request_type}}, \"request_id\": {{a request correlation id you generate}}, \"data\": {{input matching input_schema}} } to the iframe; the editor replies with { \"type\": \"REQUEST_RESULT\", \"data\": { \"request_id\": {{the same correlation id}}, \"result\": {{result}} } } (match each reply to its request by this id), where result is { \"success\": true, \"data\": {{a value matching the operation's output_schema; null for ops that return nothing}} } or { \"success\": false, \"error\": { code, message } }. Each operation lists the op-specific `error_codes` it can return; on top of those, any op may also fail with a gateway/permission code (origin/plan/signup gating, editor-not-ready, or an internal error). Every code is within `editor_error_schema` (the complete closed union to narrow against), where each code const carries a `description` of its meaning. Outbound events (see `events`) are pushed the same way. `operations` lists every operation the editor supports. Most require the embedding origin to be allowlisted (\"whitelisted\") for the tenant in the SimplePDF admin dashboard, and the tenant plan to permit them (LOAD_DOCUMENT is always available). A call the current setup does not permit returns the matching gateway code: forbidden:origin_not_whitelisted when the origin is not allowlisted, or bad_request:plan_upgrade_required when the plan excludes it. All JSON schemas use the json_schema_dialect declared at the root. We recommend the @simplepdf/embed package for a typed, ergonomic wrapper over this contract (https://github.com/SimplePDF/simplepdf-embed).", "json_schema_dialect": "https://json-schema.org/draft/2020-12/schema", "protocol": { @@ -57,7 +57,7 @@ "const": false }, "error": { - "description": "The error object when success is false: a `code` plus a human-readable `message` (with typed `details` for bad_request:missing_required_fields). Its full shape and code set are editor_error_schema, where each code const carries a description of its meaning." + "description": "The error object when success is false: a `code` plus a human-readable `message` (with typed `details` for bad_request:batch_validation_failed and bad_request:missing_required_fields). Its full shape and code set are editor_error_schema, where each code const carries a description of its meaning." } }, "required": ["success", "error"] @@ -76,6 +76,89 @@ } }, "operations": [ + { + "request_type": "BATCH_SET_FIELD_VALUE", + "description": "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", + "input_schema": { + "type": "object", + "properties": { + "updates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "string", + "description": "ID of the field to update." + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + } + }, + "required": ["field_id", "value"] + }, + "description": "The fields to set, at least one, each field at most once. Fields not listed keep their value." + } + }, + "required": ["updates"], + "description": "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data." + }, + "output_schema": { + "type": "null" + }, + "error_codes": [ + "bad_request:batch_validation_failed", + "bad_request:invalid_value", + "bad_request:no_document_loaded" + ], + "tool": { + "name": "simplepdf_embed_batch_set_field_value", + "description": "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", + "input_schema": { + "type": "object", + "properties": { + "updates": { + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "string", + "description": "ID of the field to update." + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + } + }, + "required": ["field_id", "value"] + }, + "description": "The fields to set, at least one, each field at most once. Fields not listed keep their value.", + "type": "array" + } + }, + "required": ["updates"] + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": true + } + } + }, { "request_type": "CREATE_FIELD", "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", @@ -305,7 +388,10 @@ }, "required": ["detected_count"] }, - "error_codes": ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"], + "error_codes": [ + "forbidden:editing_not_allowed", + "bad_request:no_document_loaded" + ], "tool": { "name": "simplepdf_embed_detect_fields", "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", @@ -379,7 +465,11 @@ }, "required": ["hint"] }, - "error_codes": ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"], + "error_codes": [ + "bad_request:invalid_value", + "bad_request:no_document_loaded", + "bad_request:field_not_found" + ], "tool": { "name": "simplepdf_embed_focus_field", "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", @@ -439,9 +529,19 @@ "description": "Badge number (as drawn on the render) to field_id, for every field on the page." } }, - "required": ["page", "image_data_url", "image_width", "image_height", "badges"] + "required": [ + "page", + "image_data_url", + "image_width", + "image_height", + "badges" + ] }, - "error_codes": ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"], + "error_codes": [ + "bad_request:no_document_loaded", + "bad_request:invalid_page", + "bad_request:page_out_of_range" + ], "tool": { "name": "simplepdf_embed_get_annotated_page", "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", @@ -499,7 +599,10 @@ }, "required": ["name", "pages"] }, - "error_codes": ["bad_request:invalid_value", "bad_request:no_document_loaded"], + "error_codes": [ + "bad_request:invalid_value", + "bad_request:no_document_loaded" + ], "tool": { "name": "simplepdf_embed_get_document_content", "description": "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", @@ -550,7 +653,15 @@ }, "type": { "type": "string", - "enum": ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT", "DROPDOWN", "RADIO"] + "enum": [ + "TEXT", + "SIGNATURE", + "PICTURE", + "CHECKBOX", + "COMB_TEXT", + "DROPDOWN", + "RADIO" + ] }, "page": { "type": "integer", @@ -582,7 +693,14 @@ "description": "Valid values for this field (a DROPDOWN/RADIO's choices), or null. When set, a value set on or created for the field must be one of these." } }, - "required": ["field_id", "name", "type", "page", "value", "options"] + "required": [ + "field_id", + "name", + "type", + "page", + "value", + "options" + ] } } }, @@ -619,7 +737,11 @@ "output_schema": { "type": "null" }, - "error_codes": ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"], + "error_codes": [ + "bad_request:no_document_loaded", + "bad_request:invalid_page", + "bad_request:page_out_of_range" + ], "tool": { "name": "simplepdf_embed_go_to", "description": "Scroll the editor to a specific 1-based page. Returns no data.", @@ -663,7 +785,11 @@ "output_schema": { "type": "null" }, - "error_codes": ["bad_request:failed_to_load_document", "bad_request:invalid_value", "bad_request:invalid_page"], + "error_codes": [ + "bad_request:failed_to_load_document", + "bad_request:invalid_value", + "bad_request:invalid_page" + ], "tool": { "name": "simplepdf_embed_load_document", "description": "Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Replies once the document and its fields are ready, so the next operation can act on it, and DOCUMENT_LOADED follows the reply; the URL already open is left as is and answers once it settles; a document that cannot be loaded or prepared answers bad_request:failed_to_load_document. Returns no data.", @@ -793,7 +919,13 @@ "anyOf": [ { "type": "string", - "enum": ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"] + "enum": [ + "TEXT", + "SIGNATURE", + "PICTURE", + "CHECKBOX", + "COMB_TEXT" + ] }, { "type": "null" @@ -808,7 +940,10 @@ "output_schema": { "type": "null" }, - "error_codes": ["bad_request:no_document_loaded", "bad_request:invalid_tool"], + "error_codes": [ + "bad_request:no_document_loaded", + "bad_request:invalid_tool" + ], "tool": { "name": "simplepdf_embed_select_tool", "description": "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.", @@ -819,7 +954,13 @@ "anyOf": [ { "type": "string", - "enum": ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"] + "enum": [ + "TEXT", + "SIGNATURE", + "PICTURE", + "CHECKBOX", + "COMB_TEXT" + ] }, { "type": "null" @@ -868,11 +1009,11 @@ "type": "null" }, "error_codes": [ - "bad_request:invalid_value", + "bad_request:field_not_found", "bad_request:invalid_signature_url", - "bad_request:no_document_loaded", + "bad_request:invalid_value", "bad_request:read_only", - "bad_request:field_not_found" + "bad_request:no_document_loaded" ], "tool": { "name": "simplepdf_embed_set_field_value", @@ -1017,6 +1158,65 @@ "locales": ["fr", "en", "it", "de", "pt", "es", "ja", "nl"], "editor_error_schema": { "anyOf": [ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "bad_request:batch_validation_failed", + "description": "At least one update of batch_set_field_value was rejected, so no field changed. details.failures lists every rejected update with its field_id and the set_field_value code and message explaining why." + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "properties": { + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "string", + "const": "bad_request:field_not_found", + "description": "No field on the loaded document matches the provided field_id." + }, + { + "type": "string", + "const": "bad_request:invalid_signature_url", + "description": "The signature value is not a valid, reachable image URL." + }, + { + "type": "string", + "const": "bad_request:invalid_value", + "description": "The provided value is missing or not valid for this operation or field type." + }, + { + "type": "string", + "const": "bad_request:read_only", + "description": "The document or field is read-only and cannot be modified." + } + ] + }, + "message": { + "type": "string" + } + }, + "required": ["field_id", "code", "message"] + } + } + }, + "required": ["failures"] + } + }, + "required": ["code", "message", "details"] + }, { "type": "object", "properties": { diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index 0d7c1df9..3afe6493 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -5,12 +5,28 @@ ```ts // @public (undocumented) -export type BridgeError = { - code: 'bad_request:missing_required_fields'; - message: string; - details: MissingRequiredFieldsDetails; -} | { - code: Exclude; +export type BatchSetFieldValueInput = { + updates: Array<{ + fieldId: string; + value: string | null; + }>; +}; + +// @public (undocumented) +export type BatchValidationFailedDetails = { + failures: Array<{ + fieldId: string; + code: "bad_request:field_not_found" | "bad_request:invalid_signature_url" | "bad_request:invalid_value" | "bad_request:read_only"; + message: string; + }>; +}; + +// Warning: (ae-forgotten-export) The symbol "DetailedBridgeError" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "EditorErrorDetailsByCode" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type BridgeError = DetailedBridgeError | { + code: Exclude; message: string; }; @@ -264,6 +280,7 @@ export type GoToInput = { // @public (undocumented) export type IframeActions = { + batchSetFieldValue: (input: BatchSetFieldValueInput) => Promise; createField: (input: CreateFieldInput) => Promise>; deleteFields: (input?: DeleteFieldsInput) => Promise>; deletePages: (input: DeletePagesInput) => Promise; diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index b4e9d971..f065045b 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -5,7 +5,7 @@ ```ts // @public (undocumented) -export const EDITOR_ERROR_CODES: readonly ["bad_request:download_blocked", "bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:failed_to_load_document", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"]; +export const EDITOR_ERROR_CODES: readonly ["bad_request:batch_validation_failed", "bad_request:download_blocked", "bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:failed_to_load_document", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"]; // @public (undocumented) export const EXTRACTION_MODES: readonly ["auto", "ocr"]; @@ -27,6 +27,14 @@ export const LOCALES: readonly ["fr", "en", "it", "de", "pt", "es", "ja", "nl"]; // @public (undocumented) export const OPERATIONS: readonly [{ + readonly request_type: "BATCH_SET_FIELD_VALUE"; + readonly wire_type: "BATCH_SET_FIELD_VALUE"; + readonly method: "batchSetFieldValue"; + readonly description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data."; + readonly error_codes: readonly ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { readonly request_type: "CREATE_FIELD"; readonly wire_type: "CREATE_FIELD"; readonly method: "createField"; @@ -143,7 +151,7 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SET_FIELD_VALUE"; readonly method: "setFieldValue"; readonly description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data."; - readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"]; + readonly error_codes: readonly ["bad_request:field_not_found", "bad_request:invalid_signature_url", "bad_request:invalid_value", "bad_request:read_only", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: false; }, { @@ -184,13 +192,13 @@ export const OVERLAY_TOOL_TYPES: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHEC export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number]; // @public (undocumented) -export const REQUEST_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; +export const REQUEST_TYPES: ("BATCH_SET_FIELD_VALUE" | "CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; // @public (undocumented) export type RequestType = (typeof OPERATIONS)[number]["request_type"]; // @public (undocumented) -export const WIRE_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; +export const WIRE_TYPES: ("BATCH_SET_FIELD_VALUE" | "CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; // @public (undocumented) export type WireType = (typeof OPERATIONS)[number]["wire_type"]; diff --git a/embed/etc/schemas.api.md b/embed/etc/schemas.api.md index 822dadcb..1d475783 100644 --- a/embed/etc/schemas.api.md +++ b/embed/etc/schemas.api.md @@ -6,6 +6,17 @@ import { z } from 'zod'; +// @public (undocumented) +export const BatchSetFieldValueInput: z.ZodObject<{ + updates: z.ZodArray; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type BatchSetFieldValueInput = z.infer; + // @public (undocumented) export const CreateFieldInput: z.ZodObject<{ type: z.ZodEnum<{ diff --git a/embed/etc/tanstack-ai.api.md b/embed/etc/tanstack-ai.api.md index 8753caba..cdd4656f 100644 --- a/embed/etc/tanstack-ai.api.md +++ b/embed/etc/tanstack-ai.api.md @@ -26,7 +26,7 @@ export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS; // Warnings were encountered during analysis: // -// dist/tanstack-ai.d.ts:61:5 - (ae-forgotten-export) The symbol "Embed" needs to be exported by the entry point tanstack-ai.d.ts +// dist/tanstack-ai.d.ts:66:5 - (ae-forgotten-export) The symbol "Embed" needs to be exported by the entry point tanstack-ai.d.ts // (No @packageDocumentation comment for this package) diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md index 97239f8d..01f0efbd 100644 --- a/embed/etc/tools.api.md +++ b/embed/etc/tools.api.md @@ -18,6 +18,15 @@ export const routeToolCall: (actions: IframeActions, toolName: SimplePDFToolName // @public (undocumented) export const SIMPLEPDF_TOOLS: { + readonly batchSetFieldValue: { + readonly description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data."; + readonly inputSchema: zod.ZodObject<{ + updates: zod.ZodArray; + }, zod_v4_core.$strip>>; + }, zod_v4_core.$strip>; + }; readonly createField: { readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; readonly inputSchema: zod.ZodObject<{ diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index 2d436271..b5598f1c 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -5,77 +5,99 @@ // the consumers that trigger it), so the two costs stay visible separately. Export loadability is guarded // separately by ../../scripts/check-exports.mjs (the `check:exports` script). -import { existsSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { gzipSync } from 'node:zlib' -import { LAZY_CHUNKS } from './lazy-chunks.mjs' +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { LAZY_CHUNKS } from "./lazy-chunks.mjs"; -const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') +const DIST = join(dirname(fileURLToPath(import.meta.url)), "..", "dist"); // Gzip budget (bytes) per entry's local closure. Each cap sits 0.5–1.5 KB above the // measured size, so any non-trivial growth trips the gate and gets reviewed. // The zero-dep root carries the bridge + createEmbed (create + attach paths) + its // actionable config validation + the WebMCP opt-in hook. const BUDGETS = { - 'index.js': 10 * 1024, - 'protocol.js': 3.5 * 1024, - 'schemas.js': 3 * 1024, - 'tools.js': 5 * 1024, - 'ai-sdk.js': 5.5 * 1024, - 'tanstack-ai.js': 5.5 * 1024, -} + "index.js": 10 * 1024, + "protocol.js": 3.5 * 1024, + "schemas.js": 3 * 1024, + "tools.js": 6 * 1024, + "ai-sdk.js": 6.5 * 1024, + "tanstack-ai.js": 6.5 * 1024, +}; const importsOf = (file, pattern) => { - const content = readFileSync(join(DIST, file), 'utf8') - return [...content.matchAll(pattern)].map((match) => match[1].replace(/^\.\//, '')) -} -const localImports = (file) => importsOf(file, /from\s*['"](\.\/[^'"]+)['"]/g) -const lazyImports = (file) => importsOf(file, /import\(['"](\.\/[^'"]+)['"]\)/g) + const content = readFileSync(join(DIST, file), "utf8"); + return [...content.matchAll(pattern)].map((match) => + match[1].replace(/^\.\//, "") + ); +}; +const localImports = (file) => importsOf(file, /from\s*['"](\.\/[^'"]+)['"]/g); +const lazyImports = (file) => + importsOf(file, /import\(['"](\.\/[^'"]+)['"]\)/g); const closureOf = (entry) => { - const seen = new Set() + const seen = new Set(); const walk = (file) => { if (seen.has(file) || !existsSync(join(DIST, file))) { - return + return; } - seen.add(file) + seen.add(file); for (const dependency of localImports(file)) { - walk(dependency) + walk(dependency); } - } - walk(entry) - return [...seen] -} + }; + walk(entry); + return [...seen]; +}; const gzipBytes = (files) => - files.reduce((total, file) => total + gzipSync(readFileSync(join(DIST, file))).length, 0) + files.reduce( + (total, file) => total + gzipSync(readFileSync(join(DIST, file))).length, + 0 + ); const checkBudget = (entry, budget) => { - const size = gzipBytes(closureOf(entry)) - const ok = size <= budget - console.log(`${ok ? '✓' : '✗'} ${entry}: ${size} B gzip (budget ${budget} B)`) - return ok -} + const size = gzipBytes(closureOf(entry)); + const ok = size <= budget; + console.log( + `${ok ? "✓" : "✗"} ${entry}: ${size} B gzip (budget ${budget} B)` + ); + return ok; +}; const entriesWithinBudget = Object.entries(BUDGETS).map(([entry, budget]) => { if (!existsSync(join(DIST, entry))) { - console.error(`✗ ${entry}: missing from dist (run \`npm run build\` first)`) - return false + console.error( + `✗ ${entry}: missing from dist (run \`npm run build\` first)` + ); + return false; } - return checkBudget(entry, budget) -}) + return checkBudget(entry, budget); +}); // Every lazy chunk an entry references must be built and budgeted; a lazy import // with no budget row is an unmeasured download. -const lazyChunks = [...new Set(Object.keys(BUDGETS).flatMap((entry) => closureOf(entry).flatMap(lazyImports)))] +const lazyChunks = [ + ...new Set( + Object.keys(BUDGETS).flatMap((entry) => + closureOf(entry).flatMap(lazyImports) + ) + ), +]; const lazyWithinBudget = lazyChunks.map((chunk) => { - const lazyChunk = Object.entries(LAZY_CHUNKS).find(([prefix]) => chunk.startsWith(prefix)) + const lazyChunk = Object.entries(LAZY_CHUNKS).find(([prefix]) => + chunk.startsWith(prefix) + ); if (lazyChunk === undefined || !existsSync(join(DIST, chunk))) { - console.error(`✗ ${chunk}: lazily imported but not built or not budgeted (add a row to lazy-chunks.mjs)`) - return false + console.error( + `✗ ${chunk}: lazily imported but not built or not budgeted (add a row to lazy-chunks.mjs)` + ); + return false; } - return checkBudget(chunk, lazyChunk[1].budgetBytes) -}) + return checkBudget(chunk, lazyChunk[1].budgetBytes); +}); -process.exit([...entriesWithinBudget, ...lazyWithinBudget].every(Boolean) ? 0 : 1) +process.exit( + [...entriesWithinBudget, ...lazyWithinBudget].every(Boolean) ? 0 : 1 +); diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 632d57cd..b37aec44 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -22,24 +22,26 @@ // integer/number/boolean/null/array/enum/const/anyOf), so the emitter below covers // it exhaustively rather than pulling a general json-schema-to-x dependency. -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -const HERE = dirname(fileURLToPath(import.meta.url)) -const PKG_ROOT = join(HERE, '..') -const GENERATED_DIR = join(PKG_ROOT, 'src', 'generated') +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = join(HERE, ".."); +const GENERATED_DIR = join(PKG_ROOT, "src", "generated"); -const contract = JSON.parse(readFileSync(join(PKG_ROOT, 'embed-api.json'), 'utf8')) +const contract = JSON.parse( + readFileSync(join(PKG_ROOT, "embed-api.json"), "utf8") +); // Join lines with exactly one trailing newline (no trailing blank lines that // `git diff --check` would flag). -const renderFile = (lines) => `${lines.join('\n').replace(/\n+$/, '')}\n` +const renderFile = (lines) => `${lines.join("\n").replace(/\n+$/, "")}\n`; // Operations withheld from the /tools, /ai-sdk and /tanstack-ai subpaths (the WebMCP // surface registers all of them, like the editor): load_document is a host/setup action // there (the contract description says so). -const NON_AGENTIC_OPERATIONS = new Set(['load_document']) +const NON_AGENTIC_OPERATIONS = new Set(["load_document"]); // --------------------------------------------------------------------------- // Naming helpers @@ -49,18 +51,18 @@ const NON_AGENTIC_OPERATIONS = new Set(['load_document']) * Used for method/tool names AND object property keys: the SDK surface is camelCase, * while the wire stays snake_case (the bridge transforms between them). */ const toCamel = (name) => - name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase()) + name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase()); /** snake_case request_type -> PascalCase type stem (go_to -> GoTo). */ const toPascal = (requestType) => { - const camel = toCamel(requestType) - return camel.charAt(0).toUpperCase() + camel.slice(1) -} + const camel = toCamel(requestType); + return camel.charAt(0).toUpperCase() + camel.slice(1); +}; // Known enum sets mapped to named types for a readable generated surface. // Comparison is order-insensitive on the enum members. -const enumSignature = (members) => [...members].sort().join('|') -const NAMED_ENUMS = new Map() +const enumSignature = (members) => [...members].sort().join("|"); +const NAMED_ENUMS = new Map(); // --------------------------------------------------------------------------- // JSON Schema -> TypeScript type @@ -72,193 +74,216 @@ const NAMED_ENUMS = new Map() // generated types/schemas. `additionalProperties` is honored only as a schema on // an object with no `properties` (a map: Record / z.record). const KNOWN_SCHEMA_KEYWORDS = new Set([ - 'type', - 'enum', - 'const', - 'anyOf', - 'properties', - 'additionalProperties', - 'required', - 'items', - 'description', -]) + "type", + "enum", + "const", + "anyOf", + "properties", + "additionalProperties", + "required", + "items", + "description", +]); const assertKnownKeywords = (node) => { for (const keyword of Object.keys(node)) { if (!KNOWN_SCHEMA_KEYWORDS.has(keyword)) { throw new Error( - `Unsupported JSON Schema keyword '${keyword}' in ${JSON.stringify(node)} — extend the generator to honor it`, - ) + `Unsupported JSON Schema keyword '${keyword}' in ${JSON.stringify( + node + )} — extend the generator to honor it` + ); } } if (node.additionalProperties !== undefined && !isMapNode(node)) { throw new Error( - `Unsupported 'additionalProperties' in ${JSON.stringify(node)} — only a schema on an object without 'properties' is honored (a map)`, - ) + `Unsupported 'additionalProperties' in ${JSON.stringify( + node + )} — only a schema on an object without 'properties' is honored (a map)` + ); } if (isMapNode(node) && node.required !== undefined) { - throw new Error(`Unsupported 'required' on a map node ${JSON.stringify(node)} — a map has no fixed keys to require`) + throw new Error( + `Unsupported 'required' on a map node ${JSON.stringify( + node + )} — a map has no fixed keys to require` + ); } -} +}; // An object whose every key maps to one value schema (`{ additionalProperties: }` // with no `properties`): emitted as Record / z.record. const isMapNode = (node) => - node.type === 'object' && + node.type === "object" && node.properties === undefined && - typeof node.additionalProperties === 'object' && - node.additionalProperties !== null + typeof node.additionalProperties === "object" && + node.additionalProperties !== null; // Recursively assert every node in a schema tree carries only known keywords, so // a new constraint anywhere in the manifest (op I/O, events, the error schema, or // the protocol envelopes) fails the build instead of being silently ignored. const preflightSchema = (node) => { - if (typeof node !== 'object' || node === null) { - return + if (typeof node !== "object" || node === null) { + return; } - assertKnownKeywords(node) + assertKnownKeywords(node); if (node.properties !== undefined) { for (const sub of Object.values(node.properties)) { - preflightSchema(sub) + preflightSchema(sub); } } if (node.items !== undefined) { - preflightSchema(node.items) + preflightSchema(node.items); } if (isMapNode(node)) { - preflightSchema(node.additionalProperties) + preflightSchema(node.additionalProperties); } if (Array.isArray(node.anyOf)) { for (const sub of node.anyOf) { - preflightSchema(sub) + preflightSchema(sub); } } -} +}; const tsForEnum = (members) => { - const named = NAMED_ENUMS.get(enumSignature(members)) + const named = NAMED_ENUMS.get(enumSignature(members)); if (named !== undefined) { - return named + return named; } - return members.map((m) => JSON.stringify(m)).join(' | ') -} + return members.map((m) => JSON.stringify(m)).join(" | "); +}; // `camelKeys` controls property-key casing: true for OPERATION payloads (the SDK's // camelCase method args / results — the bridge transforms them to snake on the wire), // false for EVENT payloads (forwarded to onEmbedEvent VERBATIM, so they keep the // editor's snake_case to stay compatible with the 1.x EmbedEvent contract). const tsForNode = (node, camelKeys) => { - assertKnownKeywords(node) + assertKnownKeywords(node); if (node.const !== undefined) { - return JSON.stringify(node.const) + return JSON.stringify(node.const); } if (Array.isArray(node.enum)) { - return tsForEnum(node.enum) + return tsForEnum(node.enum); } if (Array.isArray(node.anyOf)) { - return node.anyOf.map((sub) => tsForNode(sub, camelKeys)).join(' | ') + return node.anyOf.map((sub) => tsForNode(sub, camelKeys)).join(" | "); } switch (node.type) { - case 'string': - return 'string' - case 'integer': - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'null': - return 'null' - case 'array': { - const item = tsForNode(node.items, camelKeys) - return /[ |]/.test(item) ? `Array<${item}>` : `${item}[]` + case "string": + return "string"; + case "integer": + case "number": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "array": { + const item = tsForNode(node.items, camelKeys); + return /[ |]/.test(item) ? `Array<${item}>` : `${item}[]`; } - case 'object': - return tsForObject(node, camelKeys) + case "object": + return tsForObject(node, camelKeys); default: - throw new Error(`Unsupported JSON Schema node for TS: ${JSON.stringify(node)}`) + throw new Error( + `Unsupported JSON Schema node for TS: ${JSON.stringify(node)}` + ); } -} +}; const tsForObject = (node, camelKeys) => { if (isMapNode(node)) { - return `Record` + return `Record`; } - const properties = node.properties ?? {} - const required = new Set(node.required ?? []) - const keys = Object.keys(properties) + const properties = node.properties ?? {}; + const required = new Set(node.required ?? []); + const keys = Object.keys(properties); if (keys.length === 0) { - return 'Record' + return "Record"; } const members = keys.map((key) => { - const optional = required.has(key) ? '' : '?' - return `${camelKeys ? toCamel(key) : key}${optional}: ${tsForNode(properties[key], camelKeys)}` - }) - return `{ ${members.join('; ')} }` -} + const optional = required.has(key) ? "" : "?"; + return `${camelKeys ? toCamel(key) : key}${optional}: ${tsForNode( + properties[key], + camelKeys + )}`; + }); + return `{ ${members.join("; ")} }`; +}; // --------------------------------------------------------------------------- // JSON Schema -> zod expression // --------------------------------------------------------------------------- const describe = (expr, node) => - typeof node.description === 'string' ? `${expr}.describe(${JSON.stringify(node.description)})` : expr + typeof node.description === "string" + ? `${expr}.describe(${JSON.stringify(node.description)})` + : expr; const zodForNode = (node, { withDescription }) => { - assertKnownKeywords(node) + assertKnownKeywords(node); const base = (() => { if (node.const !== undefined) { - return `z.literal(${JSON.stringify(node.const)})` + return `z.literal(${JSON.stringify(node.const)})`; } if (Array.isArray(node.enum)) { - return `z.enum([${node.enum.map((m) => JSON.stringify(m)).join(', ')}])` + return `z.enum([${node.enum.map((m) => JSON.stringify(m)).join(", ")}])`; } if (Array.isArray(node.anyOf)) { // Collapse the common `{...} | { type: 'null' }` shape into `.nullable()`. - const nonNull = node.anyOf.filter((sub) => sub.type !== 'null') - const hasNull = node.anyOf.some((sub) => sub.type === 'null') + const nonNull = node.anyOf.filter((sub) => sub.type !== "null"); + const hasNull = node.anyOf.some((sub) => sub.type === "null"); if (hasNull && nonNull.length === 1) { - return `${zodForNode(nonNull[0], { withDescription: false })}.nullable()` + return `${zodForNode(nonNull[0], { + withDescription: false, + })}.nullable()`; } - return `z.union([${node.anyOf.map((sub) => zodForNode(sub, { withDescription: false })).join(', ')}])` + return `z.union([${node.anyOf + .map((sub) => zodForNode(sub, { withDescription: false })) + .join(", ")}])`; } switch (node.type) { - case 'string': - return 'z.string()' - case 'integer': - return 'z.number().int()' - case 'number': - return 'z.number()' - case 'boolean': - return 'z.boolean()' - case 'null': - return 'z.null()' - case 'array': - return `z.array(${zodForNode(node.items, { withDescription: false })})` - case 'object': - return zodForObject(node) + case "string": + return "z.string()"; + case "integer": + return "z.number().int()"; + case "number": + return "z.number()"; + case "boolean": + return "z.boolean()"; + case "null": + return "z.null()"; + case "array": + return `z.array(${zodForNode(node.items, { withDescription: false })})`; + case "object": + return zodForObject(node); default: - throw new Error(`Unsupported JSON Schema node for zod: ${JSON.stringify(node)}`) + throw new Error( + `Unsupported JSON Schema node for zod: ${JSON.stringify(node)}` + ); } - })() - return withDescription ? describe(base, node) : base -} + })(); + return withDescription ? describe(base, node) : base; +}; const zodForObject = (node) => { if (isMapNode(node)) { - return `z.record(z.string(), ${zodForNode(node.additionalProperties, { withDescription: false })})` + return `z.record(z.string(), ${zodForNode(node.additionalProperties, { + withDescription: false, + })})`; } - const properties = node.properties ?? {} - const required = new Set(node.required ?? []) - const keys = Object.keys(properties) + const properties = node.properties ?? {}; + const required = new Set(node.required ?? []); + const keys = Object.keys(properties); if (keys.length === 0) { - return 'z.object({})' + return "z.object({})"; } const members = keys.map((key) => { - const inner = zodForNode(properties[key], { withDescription: true }) - const withOptional = required.has(key) ? inner : `${inner}.optional()` - return ` ${toCamel(key)}: ${withOptional},` - }) - return `z.object({\n${members.join('\n')}\n})` -} + const inner = zodForNode(properties[key], { withDescription: true }); + const withOptional = required.has(key) ? inner : `${inner}.optional()`; + return ` ${toCamel(key)}: ${withOptional},`; + }); + return `z.object({\n${members.join("\n")}\n})`; +}; // --------------------------------------------------------------------------- // Extract the closed editor-error code set from editor_error_schema. @@ -267,25 +292,25 @@ const zodForObject = (node) => { // --------------------------------------------------------------------------- const collectErrorCodes = (schema) => { - const codes = new Set() + const codes = new Set(); const visitCodeNode = (codeNode) => { - if (typeof codeNode.const === 'string') { - codes.add(codeNode.const) + if (typeof codeNode.const === "string") { + codes.add(codeNode.const); } if (Array.isArray(codeNode.anyOf)) { for (const sub of codeNode.anyOf) { - visitCodeNode(sub) + visitCodeNode(sub); } } - } + }; for (const variant of schema.anyOf ?? []) { - const codeNode = variant.properties?.code + const codeNode = variant.properties?.code; if (codeNode !== undefined) { - visitCodeNode(codeNode) + visitCodeNode(codeNode); } } - return [...codes].sort() -} + return [...codes].sort(); +}; // --------------------------------------------------------------------------- // Resolve the named enum aliases against the real contract, then build outputs. @@ -293,287 +318,439 @@ const collectErrorCodes = (schema) => { // Fail-loud preflight over every JSON Schema root in the manifest. for (const op of contract.operations) { - preflightSchema(op.input_schema) - preflightSchema(op.output_schema) + preflightSchema(op.input_schema); + preflightSchema(op.output_schema); } for (const event of contract.events) { - preflightSchema(event.payload_schema) + preflightSchema(event.payload_schema); } -preflightSchema(contract.editor_error_schema) -preflightSchema(contract.protocol.request_envelope_schema) -preflightSchema(contract.protocol.result_envelope_schema) +preflightSchema(contract.editor_error_schema); +preflightSchema(contract.protocol.request_envelope_schema); +preflightSchema(contract.protocol.result_envelope_schema); // Keyed by lowercase request_type so the hardcoded lookups below are agnostic to // the manifest's request_type casing (snake_case or SCREAMING_SNAKE). const operationsByType = Object.fromEntries( - contract.operations.map((op) => [op.request_type.toLowerCase(), op]), -) + contract.operations.map((op) => [op.request_type.toLowerCase(), op]) +); -const fieldRecordSchema = operationsByType.get_fields.output_schema.properties.fields.items -const fieldTypes = fieldRecordSchema.properties.type.enum -const overlayToolTypes = operationsByType.create_field.input_schema.properties.type.enum -const extractionModes = operationsByType.get_document_content.input_schema.properties.extraction_mode.enum +const fieldRecordSchema = + operationsByType.get_fields.output_schema.properties.fields.items; +const fieldTypes = fieldRecordSchema.properties.type.enum; +const overlayToolTypes = + operationsByType.create_field.input_schema.properties.type.enum; +const extractionModes = + operationsByType.get_document_content.input_schema.properties.extraction_mode + .enum; -NAMED_ENUMS.set(enumSignature(fieldTypes), 'FieldType') -NAMED_ENUMS.set(enumSignature(overlayToolTypes), 'OverlayToolType') -NAMED_ENUMS.set(enumSignature(extractionModes), 'ExtractionMode') -NAMED_ENUMS.set(enumSignature(contract.locales), 'Locale') +NAMED_ENUMS.set(enumSignature(fieldTypes), "FieldType"); +NAMED_ENUMS.set(enumSignature(overlayToolTypes), "OverlayToolType"); +NAMED_ENUMS.set(enumSignature(extractionModes), "ExtractionMode"); +NAMED_ENUMS.set(enumSignature(contract.locales), "Locale"); -const editorErrorCodes = collectErrorCodes(contract.editor_error_schema) +const editorErrorCodes = collectErrorCodes(contract.editor_error_schema); const constArray = (name, values, typeName) => { - const literals = values.map((v) => JSON.stringify(v)).join(', ') + const literals = values.map((v) => JSON.stringify(v)).join(", "); return ( `export const ${name} = [${literals}] as const\n` + `export type ${typeName} = (typeof ${name})[number]\n` - ) -} + ); +}; // --- contract.ts (zero runtime deps) --------------------------------------- -const contractLines = [] -contractLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') -contractLines.push('// Zero runtime dependencies: the zero-dep root imports only from this module and method-names.ts.') -contractLines.push("import type { METHOD_NAMES } from './method-names'") -contractLines.push('') -contractLines.push(constArray('LOCALES', contract.locales, 'Locale')) -contractLines.push(constArray('EDITOR_ERROR_CODES', editorErrorCodes, 'EditorErrorCode')) -contractLines.push(constArray('FIELD_TYPES', fieldTypes, 'FieldType')) -contractLines.push(constArray('OVERLAY_TOOL_TYPES', overlayToolTypes, 'OverlayToolType')) -contractLines.push(constArray('EXTRACTION_MODES', extractionModes, 'ExtractionMode')) +const contractLines = []; +contractLines.push( + "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." +); +contractLines.push( + "// Zero runtime dependencies: the zero-dep root imports only from this module and method-names.ts." +); +contractLines.push("import type { METHOD_NAMES } from './method-names'"); +contractLines.push(""); +contractLines.push(constArray("LOCALES", contract.locales, "Locale")); +contractLines.push( + constArray("EDITOR_ERROR_CODES", editorErrorCodes, "EditorErrorCode") +); +contractLines.push(constArray("FIELD_TYPES", fieldTypes, "FieldType")); +contractLines.push( + constArray("OVERLAY_TOOL_TYPES", overlayToolTypes, "OverlayToolType") +); +contractLines.push( + constArray("EXTRACTION_MODES", extractionModes, "ExtractionMode") +); // Per-operation input/output types. for (const op of contract.operations) { - const stem = toPascal(op.request_type) - contractLines.push(`export type ${stem}Input = ${tsForNode(op.input_schema, true)}`) - contractLines.push(`export type ${stem}Output = ${tsForNode(op.output_schema, true)}`) + const stem = toPascal(op.request_type); + contractLines.push( + `export type ${stem}Input = ${tsForNode(op.input_schema, true)}` + ); + contractLines.push( + `export type ${stem}Output = ${tsForNode(op.output_schema, true)}` + ); } -contractLines.push('') +contractLines.push(""); // Ergonomic aliases over the get_document_content output shape. -contractLines.push('export type DocumentContentResult = GetDocumentContentOutput') -contractLines.push("export type DocumentContentPage = GetDocumentContentOutput['pages'][number]") -contractLines.push('') - -// The typed `details` payload carried by the one error code that has them, derived -// from editor_error_schema so the package's BridgeError never restates it. -const missingFieldsVariant = (contract.editor_error_schema.anyOf ?? []).find( - (variant) => variant.properties?.code?.const === 'bad_request:missing_required_fields', -) -if (missingFieldsVariant?.properties?.details === undefined) { - throw new Error('embed-api.json: the bad_request:missing_required_fields variant lacks a details schema') -} contractLines.push( - `export type MissingRequiredFieldsDetails = ${tsForNode(missingFieldsVariant.properties.details, true)}`, -) -contractLines.push('') + "export type DocumentContentResult = GetDocumentContentOutput" +); +contractLines.push( + "export type DocumentContentPage = GetDocumentContentOutput['pages'][number]" +); +contractLines.push(""); + +// The typed `details` payload of every error code that carries one, derived from +// editor_error_schema so the package's BridgeError never restates it: one +// `Details` type per code ('bad_request:missing_required_fields' -> +// MissingRequiredFieldsDetails) and the code -> details map BridgeError derives from. +const detailedErrorVariants = (contract.editor_error_schema.anyOf ?? []).filter( + (variant) => + typeof variant.properties?.code?.const === "string" && + variant.properties?.details !== undefined +); +if ( + !detailedErrorVariants.some( + (variant) => + variant.properties.code.const === "bad_request:missing_required_fields" + ) +) { + throw new Error( + "embed-api.json: the bad_request:missing_required_fields variant lacks a details schema" + ); +} +const toDetailsTypeName = (code) => + `${code + .split(":")[1] + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join("")}Details`; +for (const variant of detailedErrorVariants) { + contractLines.push( + `export type ${toDetailsTypeName( + variant.properties.code.const + )} = ${tsForNode(variant.properties.details, true)}` + ); +} +contractLines.push("export type EditorErrorDetailsByCode = {"); +for (const variant of detailedErrorVariants) { + contractLines.push( + ` '${variant.properties.code.const}': ${toDetailsTypeName( + variant.properties.code.const + )}` + ); +} +contractLines.push("}"); +contractLines.push(""); // Outbound event payload types. for (const event of contract.events) { const stem = event.event_type .toLowerCase() .replace(/_([a-z])/g, (_, c) => c.toUpperCase()) - .replace(/^[a-z]/, (c) => c.toUpperCase()) - contractLines.push(`export type ${stem}Payload = ${tsForNode(event.payload_schema, false)}`) + .replace(/^[a-z]/, (c) => c.toUpperCase()); + contractLines.push( + `export type ${stem}Payload = ${tsForNode(event.payload_schema, false)}` + ); } -contractLines.push('') +contractLines.push(""); // Operation metadata table (the camelCase `method` is the SDK method name, also the /tools tool name). const opMeta = contract.operations.map((op) => { - const stem = toPascal(op.request_type) + const stem = toPascal(op.request_type); return ( ` {\n` + ` request_type: ${JSON.stringify(op.request_type)},\n` + ` wire_type: ${JSON.stringify(op.request_type.toUpperCase())},\n` + ` method: ${JSON.stringify(toCamel(op.request_type))},\n` + ` description: ${JSON.stringify(op.description)},\n` + - ` error_codes: [${op.error_codes.map((c) => JSON.stringify(c)).join(', ')}] as const,\n` + - ` is_agentic_tool: ${!NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())},\n` + - ` has_output: ${op.output_schema.type !== 'null'},\n` + + ` error_codes: [${op.error_codes + .map((c) => JSON.stringify(c)) + .join(", ")}] as const,\n` + + ` is_agentic_tool: ${!NON_AGENTIC_OPERATIONS.has( + op.request_type.toLowerCase() + )},\n` + + ` has_output: ${op.output_schema.type !== "null"},\n` + ` } /* ${stem} */` - ) -}) -contractLines.push(`export const OPERATIONS = [\n${opMeta.join(',\n')},\n] as const`) -contractLines.push('') -contractLines.push('export type WireType = (typeof OPERATIONS)[number]["wire_type"]') -contractLines.push('export type RequestType = (typeof OPERATIONS)[number]["request_type"]') + ); +}); +contractLines.push( + `export const OPERATIONS = [\n${opMeta.join(",\n")},\n] as const` +); +contractLines.push(""); +contractLines.push( + 'export type WireType = (typeof OPERATIONS)[number]["wire_type"]' +); +contractLines.push( + 'export type RequestType = (typeof OPERATIONS)[number]["request_type"]' +); // The JS method name is the camelCase of the wire op (the SDK is camelCase; the // bridge transforms to the snake_case wire). The names live in their own tiny module // (createEmbed validates an untyped caller's `webMCP.exclude` against the runtime // list, and must not pull this whole table into the zero-dep root); the type derives // from it here, and drift.ts pins it to the OPERATIONS methods (and IframeActions to // it) so the views of one fact cannot diverge. -contractLines.push('export type MethodName = (typeof METHOD_NAMES)[number]') -contractLines.push('') +contractLines.push("export type MethodName = (typeof METHOD_NAMES)[number]"); +contractLines.push(""); const eventMeta = contract.events.map( (event) => - ` { event_type: ${JSON.stringify(event.event_type)}, description: ${JSON.stringify(event.description)} }`, -) -contractLines.push(`export const OUTBOUND_EVENTS = [\n${eventMeta.join(',\n')},\n] as const`) -contractLines.push('export type OutboundEventType = (typeof OUTBOUND_EVENTS)[number]["event_type"]') -contractLines.push('') + ` { event_type: ${JSON.stringify( + event.event_type + )}, description: ${JSON.stringify(event.description)} }` +); +contractLines.push( + `export const OUTBOUND_EVENTS = [\n${eventMeta.join(",\n")},\n] as const` +); +contractLines.push( + 'export type OutboundEventType = (typeof OUTBOUND_EVENTS)[number]["event_type"]' +); +contractLines.push(""); -mkdirSync(GENERATED_DIR, { recursive: true }) -writeFileSync(join(GENERATED_DIR, 'contract.ts'), renderFile(contractLines)) +mkdirSync(GENERATED_DIR, { recursive: true }); +writeFileSync(join(GENERATED_DIR, "contract.ts"), renderFile(contractLines)); // --- schemas.ts (zod peer dep + compile-time drift guards) ----------------- -const schemaLines = [] -schemaLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') -schemaLines.push("import { z } from 'zod'") -schemaLines.push('') +const schemaLines = []; +schemaLines.push( + "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." +); +schemaLines.push("import { z } from 'zod'"); +schemaLines.push(""); for (const op of contract.operations) { - const stem = toPascal(op.request_type) - schemaLines.push(`export const ${stem}Input = ${zodForNode(op.input_schema, { withDescription: true })}`) + const stem = toPascal(op.request_type); + schemaLines.push( + `export const ${stem}Input = ${zodForNode(op.input_schema, { + withDescription: true, + })}` + ); // Value + type share the PascalCase name (the canonical zod idiom). - schemaLines.push(`export type ${stem}Input = z.infer`) + schemaLines.push(`export type ${stem}Input = z.infer`); } -schemaLines.push('') +schemaLines.push(""); -writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) +writeFileSync(join(GENERATED_DIR, "schemas.ts"), renderFile(schemaLines)); // --- method-names.ts (zero runtime deps; the one generated value the root imports) --- -const methodNames = contract.operations.map((op) => toCamel(op.request_type)) +const methodNames = contract.operations.map((op) => toCamel(op.request_type)); writeFileSync( - join(GENERATED_DIR, 'method-names.ts'), + join(GENERATED_DIR, "method-names.ts"), renderFile([ - '// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.', - '// The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list', - '// without pulling the operations table into the zero-dep root; contract.ts derives', - '// MethodName from this list.', - `export const METHOD_NAMES = [${methodNames.map((name) => JSON.stringify(name)).join(', ')}] as const`, - ]), -) + "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.", + "// The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list", + "// without pulling the operations table into the zero-dep root; contract.ts derives", + "// MethodName from this list.", + `export const METHOD_NAMES = [${methodNames + .map((name) => JSON.stringify(name)) + .join(", ")}] as const`, + ]) +); // --- webmcp-tools.ts (zero runtime deps, loaded only by the WebMCP module) --- // The manifest's `tool` record per operation, verbatim: the same record the editor // registers on its own page. The generator only checks the record's shape (a new key // or hint fails loud) and renames `input_schema` to WebMCP's `inputSchema`. -const WEBMCP_ANNOTATION_KEYS = new Set(['destructiveHint', 'openWorldHint', 'readOnlyHint', 'untrustedContentHint']) +const WEBMCP_ANNOTATION_KEYS = new Set([ + "destructiveHint", + "openWorldHint", + "readOnlyHint", + "untrustedContentHint", +]); const webmcpToolRecord = (op) => { - const tool = op.tool - if (typeof tool !== 'object' || tool === null) { - throw new Error(`Operation ${op.request_type} publishes no tool record`) + const tool = op.tool; + if (typeof tool !== "object" || tool === null) { + throw new Error(`Operation ${op.request_type} publishes no tool record`); } - const { name, description, input_schema: inputSchema, annotations, ...unknownKeys } = tool + const { + name, + description, + input_schema: inputSchema, + annotations, + ...unknownKeys + } = tool; if (Object.keys(unknownKeys).length > 0) { throw new Error( - `Unsupported tool record keys on ${op.request_type}: ${Object.keys(unknownKeys).join(', ')} — extend the generator to honor them`, - ) + `Unsupported tool record keys on ${op.request_type}: ${Object.keys( + unknownKeys + ).join(", ")} — extend the generator to honor them` + ); } const isWellFormed = - typeof name === 'string' && - typeof description === 'string' && - inputSchema?.type === 'object' && - typeof annotations === 'object' && - annotations !== null + typeof name === "string" && + typeof description === "string" && + inputSchema?.type === "object" && + typeof annotations === "object" && + annotations !== null; if (!isWellFormed) { - throw new Error(`Malformed tool record on ${op.request_type}: ${JSON.stringify(tool)}`) + throw new Error( + `Malformed tool record on ${op.request_type}: ${JSON.stringify(tool)}` + ); } for (const hint of Object.keys(annotations)) { if (!WEBMCP_ANNOTATION_KEYS.has(hint)) { - throw new Error(`Unsupported tool annotation '${hint}' on ${op.request_type} — extend the generator to honor it`) + throw new Error( + `Unsupported tool annotation '${hint}' on ${op.request_type} — extend the generator to honor it` + ); } } - return { name, description, inputSchema, annotations, wireType: op.request_type.toUpperCase() } -} - -const webmcpToolLines = [] -webmcpToolLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') -webmcpToolLines.push('// The WebMCP tool each operation publishes (the manifest `tool`: name, description,') -webmcpToolLines.push('// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude`') -webmcpToolLines.push('// maps straight onto it. The editor registers the same record on its own page. Read') -webmcpToolLines.push('// only by src/webmcp.ts, which is lazy-loaded, so this table never lands in an entry') -webmcpToolLines.push('// that did not opt in. `wireType` is the operation the record dispatches to, carried') -webmcpToolLines.push('// here so the lazy module needs nothing from the OPERATIONS table.') -webmcpToolLines.push("import type { MethodName, WireType } from './contract'") -webmcpToolLines.push('') -webmcpToolLines.push('export type WebMCPToolRecord = {') -webmcpToolLines.push(' readonly wireType: WireType') -webmcpToolLines.push(' readonly name: string') -webmcpToolLines.push(' readonly description: string') -webmcpToolLines.push(' readonly inputSchema: {') -webmcpToolLines.push(" readonly type: 'object'") -webmcpToolLines.push(' readonly properties?: Readonly>') -webmcpToolLines.push(' readonly required?: readonly string[]') -webmcpToolLines.push(' }') -webmcpToolLines.push(' readonly annotations: {') + return { + name, + description, + inputSchema, + annotations, + wireType: op.request_type.toUpperCase(), + }; +}; + +const webmcpToolLines = []; +webmcpToolLines.push( + "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." +); +webmcpToolLines.push( + "// The WebMCP tool each operation publishes (the manifest `tool`: name, description," +); +webmcpToolLines.push( + "// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude`" +); +webmcpToolLines.push( + "// maps straight onto it. The editor registers the same record on its own page. Read" +); +webmcpToolLines.push( + "// only by src/webmcp.ts, which is lazy-loaded, so this table never lands in an entry" +); +webmcpToolLines.push( + "// that did not opt in. `wireType` is the operation the record dispatches to, carried" +); +webmcpToolLines.push( + "// here so the lazy module needs nothing from the OPERATIONS table." +); +webmcpToolLines.push("import type { MethodName, WireType } from './contract'"); +webmcpToolLines.push(""); +webmcpToolLines.push("export type WebMCPToolRecord = {"); +webmcpToolLines.push(" readonly wireType: WireType"); +webmcpToolLines.push(" readonly name: string"); +webmcpToolLines.push(" readonly description: string"); +webmcpToolLines.push(" readonly inputSchema: {"); +webmcpToolLines.push(" readonly type: 'object'"); +webmcpToolLines.push( + " readonly properties?: Readonly>" +); +webmcpToolLines.push(" readonly required?: readonly string[]"); +webmcpToolLines.push(" }"); +webmcpToolLines.push(" readonly annotations: {"); for (const hint of WEBMCP_ANNOTATION_KEYS) { - webmcpToolLines.push(` readonly ${hint}?: boolean`) + webmcpToolLines.push(` readonly ${hint}?: boolean`); } -webmcpToolLines.push(' }') -webmcpToolLines.push('}') -webmcpToolLines.push('') -webmcpToolLines.push('export const WEBMCP_TOOLS = {') +webmcpToolLines.push(" }"); +webmcpToolLines.push("}"); +webmcpToolLines.push(""); +webmcpToolLines.push("export const WEBMCP_TOOLS = {"); for (const op of contract.operations) { - webmcpToolLines.push(` ${toCamel(op.request_type)}: ${JSON.stringify(webmcpToolRecord(op))},`) + webmcpToolLines.push( + ` ${toCamel(op.request_type)}: ${JSON.stringify(webmcpToolRecord(op))},` + ); } -webmcpToolLines.push('} as const satisfies Record') +webmcpToolLines.push( + "} as const satisfies Record" +); -writeFileSync(join(GENERATED_DIR, 'webmcp-tools.ts'), renderFile(webmcpToolLines)) +writeFileSync( + join(GENERATED_DIR, "webmcp-tools.ts"), + renderFile(webmcpToolLines) +); // --- drift.ts (compile-time drift guards; type-checked, not bundled) -------- // One exported tuple gathers every guard so noUnusedLocals stays happy while the // type-parameter constraints still fail the build the instant a representation // diverges. Imported by nothing; pure type-level (no runtime). -const driftLines = [] -driftLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') -driftLines.push("import type { EditorEvent, IframeActions } from '../types'") -driftLines.push("import type * as Schemas from './schemas'") -driftLines.push("import type * as Contract from './contract'") -driftLines.push('') -driftLines.push('type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false') -driftLines.push('type AssertTrue = T') -driftLines.push('') -driftLines.push('// IframeActions method set must exactly equal the generated operation methods,') -driftLines.push('// each zod schema must stay mutually assignable to its plain contract type, and') -driftLines.push('// the hand-maintained EditorEvent union must exactly match the generated outbound events') -driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one).") -driftLines.push('export type DriftGuards = [') -driftLines.push(" AssertTrue>,") -driftLines.push(' AssertTrue>,') -driftLines.push(" AssertTrue>,") +const driftLines = []; +driftLines.push( + "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." +); +driftLines.push("import type { EditorEvent, IframeActions } from '../types'"); +driftLines.push("import type * as Schemas from './schemas'"); +driftLines.push("import type * as Contract from './contract'"); +driftLines.push(""); +driftLines.push( + "type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false" +); +driftLines.push("type AssertTrue = T"); +driftLines.push(""); +driftLines.push( + "// IframeActions method set must exactly equal the generated operation methods," +); +driftLines.push( + "// each zod schema must stay mutually assignable to its plain contract type, and" +); +driftLines.push( + "// the hand-maintained EditorEvent union must exactly match the generated outbound events" +); +driftLines.push( + "// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one)." +); +driftLines.push("export type DriftGuards = ["); +driftLines.push( + " AssertTrue>," +); +driftLines.push( + ' AssertTrue>,' +); +driftLines.push( + " AssertTrue>," +); for (const op of contract.operations) { - const stem = toPascal(op.request_type) - driftLines.push(` AssertTrue>,`) + const stem = toPascal(op.request_type); + driftLines.push( + ` AssertTrue>,` + ); } -driftLines.push(']') -driftLines.push('') +driftLines.push("]"); +driftLines.push(""); -writeFileSync(join(GENERATED_DIR, 'drift.ts'), renderFile(driftLines)) +writeFileSync(join(GENERATED_DIR, "drift.ts"), renderFile(driftLines)); // --- tools.ts (agentic tool registry: name -> { description, inputSchema }) -- const agenticOperations = contract.operations.filter( - (op) => !NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase()), -) -const toolLines = [] -toolLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') -toolLines.push("import * as Schemas from './schemas'") -toolLines.push('') -toolLines.push('// The agentic tool registry. Each tool name is the camelCase operation name;') -toolLines.push('// load_document is excluded here (a host/setup action; the WebMCP surface registers it).') -toolLines.push('export const TOOL_DEFINITIONS = {') + (op) => !NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase()) +); +const toolLines = []; +toolLines.push( + "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." +); +toolLines.push("import * as Schemas from './schemas'"); +toolLines.push(""); +toolLines.push( + "// The agentic tool registry. Each tool name is the camelCase operation name;" +); +toolLines.push( + "// load_document is excluded here (a host/setup action; the WebMCP surface registers it)." +); +toolLines.push("export const TOOL_DEFINITIONS = {"); for (const op of agenticOperations) { - const stem = toPascal(op.request_type) + const stem = toPascal(op.request_type); toolLines.push( - ` ${toCamel(op.request_type)}: { description: ${JSON.stringify(op.description)}, inputSchema: Schemas.${stem}Input },`, - ) + ` ${toCamel(op.request_type)}: { description: ${JSON.stringify( + op.description + )}, inputSchema: Schemas.${stem}Input },` + ); } -toolLines.push('} as const') -toolLines.push('') -toolLines.push('export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS') -toolLines.push('') +toolLines.push("} as const"); +toolLines.push(""); +toolLines.push("export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS"); +toolLines.push(""); -writeFileSync(join(GENERATED_DIR, 'tools.ts'), renderFile(toolLines)) +writeFileSync(join(GENERATED_DIR, "tools.ts"), renderFile(toolLines)); console.log( `Generated contract.ts (${contract.operations.length} ops, ${contract.events.length} events, ` + - `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + method-names.ts + webmcp-tools.ts`, -) + `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + method-names.ts + webmcp-tools.ts` +); diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 8679f814..863f6eb9 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -1,7 +1,7 @@ -import { fromWireData, toWireData } from './case-transform' -import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' -import { isBridgeResultLike } from './result' -import type { OutboundEventType, WireType } from './generated/contract' +import { fromWireData, toWireData } from "./case-transform"; +import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from "./logger"; +import { isBridgeResultLike } from "./result"; +import type { OutboundEventType, WireType } from "./generated/contract"; import type { BridgeResult, BridgeState, @@ -10,103 +10,128 @@ import type { IframeActions, PageFocusedPayload, SubmissionSentPayload, -} from './types' -import { modelContextCandidates, normalizeWebMCPOptions, type WebMCPOptions } from './webmcp-shared' +} from "./types"; +import { + modelContextCandidates, + normalizeWebMCPOptions, + type WebMCPOptions, +} from "./webmcp-shared"; export type AttachEmbedArgs = { // Getter returning the iframe element. Called each time the bridge needs to // reach the editor (postMessage send, probe, identity check on incoming // messages). Framework-agnostic: React callers pass `() => ref.current`, // vanilla callers pass `() => document.getElementById('my-iframe')`. - getIframe: () => HTMLIFrameElement | null + getIframe: () => HTMLIFrameElement | null; // Origin the iframe is served from. Every incoming message is verified against // this value, and it is the postMessage target origin. - editorOrigin: string - logger?: BridgeLogger + editorOrigin: string; + logger?: BridgeLogger; // Optional teardown hook invoked once on dispose() after the bridge has cleaned // up (createEmbed's create path uses it to remove the iframe it created). - onDispose?: () => void + onDispose?: () => void; // Expose the editor operations as WebMCP tools on the host page (see ./webmcp). - webMCP?: WebMCPOptions + webMCP?: WebMCPOptions; // Internal wiring for createEmbed's "load the document once ready" flow: called on // every lifecycle transition (booting -> editorReady -> documentLoaded), including // readiness reached via the liveness probe (which emits no editor event). NOT a // public event — consumers observe lifecycle via the EDITOR_READY / DOCUMENT_LOADED events. - onStateChange?: (state: BridgeState) => void -} + onStateChange?: (state: BridgeState) => void; +}; // One uniform request/response timeout. The editor is proven alive (EDITOR_READY // + the readiness probe), so this is purely a dead-iframe safety net — deliberately // generous so a request posted behind a slow FIFO op (auto-detection / OCR) is not // starved by a per-op budget that starts at enqueue, not at dispatch. Dependent // calls should be awaited rather than fired concurrently. -const REQUEST_TIMEOUT_MS = 60_000 -const EDITOR_READY_PROBE_INTERVAL_MS = 500 -const EDITOR_READY_HARD_FALLBACK_MS = 30_000 +const REQUEST_TIMEOUT_MS = 60_000; +const EDITOR_READY_PROBE_INTERVAL_MS = 500; +const EDITOR_READY_HARD_FALLBACK_MS = 30_000; // Compile-time drift guards: the outbound-event literals the bridge matches must // remain members of the generated vocabulary, or `tsc` fails (an editor rename // would otherwise silently stop the bridge emitting that event). Type-only, so // no generated value (the OPERATIONS table) is pulled into the zero-dep root. -const EDITOR_READY_EVENT: Extract = 'EDITOR_READY' -const DOCUMENT_LOADED_EVENT: Extract = 'DOCUMENT_LOADED' -const SUBMISSION_SENT_EVENT: Extract = 'SUBMISSION_SENT' -const PAGE_FOCUSED_EVENT: Extract = 'PAGE_FOCUSED' +const EDITOR_READY_EVENT: Extract = + "EDITOR_READY"; +const DOCUMENT_LOADED_EVENT: Extract = + "DOCUMENT_LOADED"; +const SUBMISSION_SENT_EVENT: Extract = + "SUBMISSION_SENT"; +const PAGE_FOCUSED_EVENT: Extract = + "PAGE_FOCUSED"; // The reply envelope (manifest `protocol`), not an event: never part of OutboundEventType. -const REQUEST_RESULT_TYPE = 'REQUEST_RESULT' +const REQUEST_RESULT_TYPE = "REQUEST_RESULT"; const generateRequestId = (): string => { - if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { - return crypto.randomUUID() + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); } - return `req_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}` -} + return `req_${Math.random().toString(36).slice(2)}_${Date.now().toString( + 36 + )}`; +}; // The single deliberate trust boundary. The editor is contracted to reply with // `data` matching the operation's output_schema; that shape cannot be re-derived // at runtime (the zero-dep root carries no validator), so this generic relabels // the validated envelope. Errors are TData-independent and pass through untouched. -const relabelResult = (result: BridgeResult): BridgeResult => { +const relabelResult = ( + result: BridgeResult +): BridgeResult => { if (result.success) { - return { success: true, data: result.data as TData } + return { success: true, data: result.data as TData }; } - return result -} + return result; +}; // Op results reach SDK callers camelCased; the WebMCP tools hand the agent the wire // shape (snake_case), the one the manifest's tool descriptions promise. -type ResultShape = 'sdk' | 'wire' +type ResultShape = "sdk" | "wire"; type PendingRequest = { - resolve: (result: BridgeResult) => void - resultShape: ResultShape - wireType: WireType - startedAtMs: number - timeoutId: ReturnType -} + resolve: (result: BridgeResult) => void; + resultShape: ResultShape; + wireType: WireType; + startedAtMs: number; + timeoutId: ReturnType; +}; // Validates + returns the editor's outbound event payload VERBATIM (snake_case): // these are forwarded to onEmbedEvent unchanged, so they keep the wire shape (the // established EmbedEvent contract). Op method payloads, by contrast, are camelCased. -const asSubmissionSent = (data: Record | undefined): SubmissionSentPayload | null => { - const documentId = data?.document_id - const submissionId = data?.submission_id - if (typeof documentId === 'string' && typeof submissionId === 'string') { - return { document_id: documentId, submission_id: submissionId } +const asSubmissionSent = ( + data: Record | undefined +): SubmissionSentPayload | null => { + const documentId = data?.document_id; + const submissionId = data?.submission_id; + if (typeof documentId === "string" && typeof submissionId === "string") { + return { document_id: documentId, submission_id: submissionId }; } - return null -} - -const asPageFocused = (data: Record | undefined): PageFocusedPayload | null => { - const previousPage = data?.previous_page - const currentPage = data?.current_page - const totalPages = data?.total_pages - const previousIsValid = previousPage === null || typeof previousPage === 'number' - if (previousIsValid && typeof currentPage === 'number' && typeof totalPages === 'number') { - return { previous_page: previousPage, current_page: currentPage, total_pages: totalPages } + return null; +}; + +const asPageFocused = ( + data: Record | undefined +): PageFocusedPayload | null => { + const previousPage = data?.previous_page; + const currentPage = data?.current_page; + const totalPages = data?.total_pages; + const previousIsValid = + previousPage === null || typeof previousPage === "number"; + if ( + previousIsValid && + typeof currentPage === "number" && + typeof totalPages === "number" + ) { + return { + previous_page: previousPage, + current_page: currentPage, + total_pages: totalPages, + }; } - return null -} + return null; +}; export const attachEmbed = ({ getIframe, @@ -116,11 +141,11 @@ export const attachEmbed = ({ onStateChange, webMCP, }: AttachEmbedArgs): Embed => { - const logger = makeSafeLogger(providedLogger) - const pending = new Map() - let state: BridgeState = { kind: 'booting' } - let documentId: string | null = null - let disposed = false + const logger = makeSafeLogger(providedLogger); + const pending = new Map(); + let state: BridgeState = { kind: "booting" }; + let documentId: string | null = null; + let disposed = false; // Per-type channels for the editor's outbound events, forwarded verbatim (the // established EmbedEvent contract). A throwing listener can't stop the others or break cleanup. @@ -128,128 +153,151 @@ export const attachEmbed = ({ // load-when-ready gate), not an event: consumers observe readiness via the // EDITOR_READY / DOCUMENT_LOADED events. type Channel = { - subscribe: (listener: (payload: T) => void) => () => void - emit: (payload: T) => void - clear: () => void - } + subscribe: (listener: (payload: T) => void) => () => void; + emit: (payload: T) => void; + clear: () => void; + }; const reportListenerError = (error: unknown): void => - logger.error('iframe.listener_threw', { message: error instanceof Error ? error.message : String(error) }) + logger.error("iframe.listener_threw", { + message: error instanceof Error ? error.message : String(error), + }); const makeChannel = (): Channel => { - const listeners = new Set<(payload: T) => void>() + const listeners = new Set<(payload: T) => void>(); return { subscribe: (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) + listeners.add(listener); + return () => listeners.delete(listener); }, emit: (payload) => { for (const listener of listeners) { try { - listener(payload) + listener(payload); } catch (error) { - reportListenerError(error) + reportListenerError(error); } } }, clear: () => listeners.clear(), - } - } + }; + }; // One channel per editor event type, so on(type, handler) hands the handler that // event's typed payload directly — no cast, no union narrowing. - const channels: { [TEventType in keyof EditorEventMap]: Channel } = { + const channels: { + [TEventType in keyof EditorEventMap]: Channel; + } = { EDITOR_READY: makeChannel(), DOCUMENT_LOADED: makeChannel(), PAGE_FOCUSED: makeChannel(), SUBMISSION_SENT: makeChannel(), - } + }; // Granular, EventEmitter-style subscription. Returns an unsubscribe fn. const on = ( type: TEventType, - handler: (data: EditorEventMap[TEventType]) => void, - ): (() => void) => channels[type].subscribe(handler) + handler: (data: EditorEventMap[TEventType]) => void + ): (() => void) => channels[type].subscribe(handler); // WebMCP tools are registered once the editor is alive (an agent enumerating tools // at page load must not post into an iframe that has no listener yet), only when the // embedder opted in and the page exposes a model context: the module and the schema // table it reads load for no one else. Aborting the signal on dispose unregisters // every tool. - const webMCPController = new AbortController() - const webMCPOptions = normalizeWebMCPOptions(webMCP) + const webMCPController = new AbortController(); + const webMCPOptions = normalizeWebMCPOptions(webMCP); // Latched while a registration attempt is in flight or succeeded; released when the // module finds no usable context or fails to load, so the next non-booting transition // probes again and a runtime that installs its context after a fast EDITOR_READY (or a // transient chunk fetch failure) still gets the tools. A transition during the load // itself needs no replay: the module probes on arrival. - let webMCPStarted = false + let webMCPStarted = false; const startWebMCP = (): void => { if (!webMCPOptions.enabled || webMCPStarted) { - return + return; } if (modelContextCandidates().length === 0) { - logger.info('webmcp.unavailable', { reason: 'no_model_context' }) - return + logger.info("webmcp.unavailable", { reason: "no_model_context" }); + return; } - webMCPStarted = true - void import('./webmcp') + webMCPStarted = true; + void import("./webmcp") .then(({ registerWebMCPTools }) => { const registered = registerWebMCPTools({ - dispatch: (wireType, data) => postRequest(wireType, data, 'wire'), + dispatch: (wireType, data) => postRequest(wireType, data, "wire"), exclude: webMCPOptions.exclude, signal: webMCPController.signal, logger, - }) + }); if (!registered) { - webMCPStarted = false + webMCPStarted = false; } }) .catch((error: unknown) => { - webMCPStarted = false - logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) - }) - } + webMCPStarted = false; + logger.error("webmcp.load_failed", { + message: error instanceof Error ? error.message : String(error), + }); + }); + }; const transitionTo = (next: BridgeState): void => { - state = next - onStateChange?.(next) - if (next.kind !== 'booting') { - startWebMCP() + state = next; + onStateChange?.(next); + if (next.kind !== "booting") { + startWebMCP(); } - } + }; - const postRequest = (wireType: WireType, data: unknown, resultShape: ResultShape): Promise> => + const postRequest = ( + wireType: WireType, + data: unknown, + resultShape: ResultShape + ): Promise> => new Promise>((resolve) => { if (disposed) { resolve({ success: false, - error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, - }) - return + error: { + code: "unexpected:bridge_disposed", + message: "Editor bridge was disposed", + }, + }); + return; } - const iframe = getIframe() + const iframe = getIframe(); if (iframe === null || iframe.contentWindow === null) { resolve({ success: false, - error: { code: 'unexpected:iframe_not_mounted', message: 'Editor iframe is not mounted' }, - }) - return + error: { + code: "unexpected:iframe_not_mounted", + message: "Editor iframe is not mounted", + }, + }); + return; } - const requestId = generateRequestId() - const startedAtMs = Date.now() + const requestId = generateRequestId(); + const startedAtMs = Date.now(); // Log identifiers + timing only — never the payload body (it can carry the // document data URL, field values, signatures, or other PII). - logger.info('iframe.request_sent', { request_id: requestId, type: wireType, timeout_ms: REQUEST_TIMEOUT_MS }) + logger.info("iframe.request_sent", { + request_id: requestId, + type: wireType, + timeout_ms: REQUEST_TIMEOUT_MS, + }); const timeoutId = setTimeout(() => { - pending.delete(requestId) - logger.error('iframe.request_timed_out', { + pending.delete(requestId); + logger.error("iframe.request_timed_out", { request_id: requestId, type: wireType, elapsed_ms: Date.now() - startedAtMs, - }) + }); resolve({ success: false, - error: { code: 'unexpected:timeout', message: `Editor request '${wireType}' timed out after ${REQUEST_TIMEOUT_MS}ms` }, - }) - }, REQUEST_TIMEOUT_MS) + error: { + code: "unexpected:timeout", + message: `Editor request '${wireType}' timed out after ${REQUEST_TIMEOUT_MS}ms`, + }, + }); + }, REQUEST_TIMEOUT_MS); pending.set(requestId, { resolve: (result) => resolve(relabelResult(result)), @@ -257,101 +305,113 @@ export const attachEmbed = ({ wireType, startedAtMs, timeoutId, - }) + }); // Serializing (circular / BigInt input) or posting (dead contentWindow) can // throw; convert that into a Result rather than rejecting the promise. try { - const outbound = JSON.stringify({ type: wireType, request_id: requestId, data: toWireData(data) }) - iframe.contentWindow.postMessage(outbound, editorOrigin) - logger.debug('iframe.request_posted', { request_id: requestId, type: wireType, char_count: outbound.length }) + const outbound = JSON.stringify({ + type: wireType, + request_id: requestId, + data: toWireData(data), + }); + iframe.contentWindow.postMessage(outbound, editorOrigin); + logger.debug("iframe.request_posted", { + request_id: requestId, + type: wireType, + char_count: outbound.length, + }); } catch (error) { - clearTimeout(timeoutId) - pending.delete(requestId) + clearTimeout(timeoutId); + pending.delete(requestId); resolve({ success: false, error: { - code: 'unexpected:unknown', - message: `Failed to post '${wireType}': ${error instanceof Error ? error.message : String(error)}`, + code: "unexpected:unknown", + message: `Failed to post '${wireType}': ${ + error instanceof Error ? error.message : String(error) + }`, }, - }) + }); } - }) + }); - const sendRequest = (wireType: WireType, data: unknown): Promise> => - postRequest(wireType, data, 'sdk') + const sendRequest = ( + wireType: WireType, + data: unknown + ): Promise> => postRequest(wireType, data, "sdk"); // --- Editor-readiness probing --------------------------------------------- // Fire a GET_FIELDS every 500ms until the editor confirms a document is loaded // (success: true) or the hard fallback fires. A success: false with // bad_request:no_document_loaded still proves the postMessage bridge is alive, // so we flip to editor_ready on the first response and keep probing for loaded. - const probeRequestIds = new Set() - let probeInterval: ReturnType | null = null + const probeRequestIds = new Set(); + let probeInterval: ReturnType | null = null; const stopProbing = (): void => { if (probeInterval !== null) { - clearInterval(probeInterval) - probeInterval = null + clearInterval(probeInterval); + probeInterval = null; } - probeRequestIds.clear() - } + probeRequestIds.clear(); + }; // `editor_ready_event` = a real EDITOR_READY message (iframe re-mounting for a // fresh document cycle). `probe`/`fallback` only prove liveness and must NEVER // drop a loaded document back to editorReady. - type EditorReadySource = 'editor_ready_event' | 'probe' | 'fallback' + type EditorReadySource = "editor_ready_event" | "probe" | "fallback"; const markEditorReady = (source: EditorReadySource): void => { switch (state.kind) { - case 'documentLoaded': - if (source === 'editor_ready_event') { - transitionTo({ kind: 'editorReady' }) + case "documentLoaded": + if (source === "editor_ready_event") { + transitionTo({ kind: "editorReady" }); } - return - case 'booting': - transitionTo({ kind: 'editorReady' }) - return - case 'editorReady': - return + return; + case "booting": + transitionTo({ kind: "editorReady" }); + return; + case "editorReady": + return; default: - state satisfies never + state satisfies never; } - } + }; - let readyTimeout: ReturnType | null = null + let readyTimeout: ReturnType | null = null; const clearReadyTimeout = (): void => { if (readyTimeout !== null) { - clearTimeout(readyTimeout) - readyTimeout = null + clearTimeout(readyTimeout); + readyTimeout = null; } - } + }; const markDocumentLoaded = (): void => { - if (state.kind === 'documentLoaded') { - return + if (state.kind === "documentLoaded") { + return; } - clearReadyTimeout() - stopProbing() - transitionTo({ kind: 'documentLoaded', documentId }) - } + clearReadyTimeout(); + stopProbing(); + transitionTo({ kind: "documentLoaded", documentId }); + }; const sendProbe = (): void => { - const iframe = getIframe() + const iframe = getIframe(); if (iframe === null || iframe.contentWindow === null) { - return + return; } - const probeId = generateRequestId() + const probeId = generateRequestId(); try { iframe.contentWindow.postMessage( - JSON.stringify({ type: 'GET_FIELDS', request_id: probeId, data: {} }), - editorOrigin, - ) + JSON.stringify({ type: "GET_FIELDS", request_id: probeId, data: {} }), + editorOrigin + ); // Track only successfully-posted probes (a failed post leaves no id to match). - probeRequestIds.add(probeId) + probeRequestIds.add(probeId); } catch { // Best-effort liveness probe; a transient post failure just retries next tick. } - } + }; // Hard readiness fallback. It only WARNS if we are still booting (the editor // never confirmed readiness), and it stops the 500ms probe loop: by this point @@ -359,208 +419,231 @@ export const attachEmbed = ({ // we rely on the DOCUMENT_LOADED event rather than probing — and accumulating // probe ids — indefinitely. readyTimeout = setTimeout(() => { - readyTimeout = null - if (state.kind === 'booting') { - logger.warn('editor.ready_fallback_timeout', { timeout_ms: EDITOR_READY_HARD_FALLBACK_MS }) + readyTimeout = null; + if (state.kind === "booting") { + logger.warn("editor.ready_fallback_timeout", { + timeout_ms: EDITOR_READY_HARD_FALLBACK_MS, + }); } - markEditorReady('fallback') - stopProbing() - }, EDITOR_READY_HARD_FALLBACK_MS) + markEditorReady("fallback"); + stopProbing(); + }, EDITOR_READY_HARD_FALLBACK_MS); - sendProbe() - probeInterval = setInterval(sendProbe, EDITOR_READY_PROBE_INTERVAL_MS) + sendProbe(); + probeInterval = setInterval(sendProbe, EDITOR_READY_PROBE_INTERVAL_MS); const onMessage = (event: MessageEvent): void => { if (event.origin !== editorOrigin) { - return + return; } - const iframe = getIframe() + const iframe = getIframe(); // Iframe torn down (ref cleared between dispatch and receive, or a message // delivered after dispose): don't transition or resolve on a dead iframe. if (iframe === null || event.source !== iframe.contentWindow) { - return + return; } - const payload = ((): { type?: string; data?: Record; request_id?: string } | null => { - if (typeof event.data !== 'string') { - return null + const payload = ((): { + type?: string; + data?: Record; + request_id?: string; + } | null => { + if (typeof event.data !== "string") { + return null; } try { - const parsed: unknown = JSON.parse(event.data) - return typeof parsed === 'object' && parsed !== null ? parsed : null + const parsed: unknown = JSON.parse(event.data); + return typeof parsed === "object" && parsed !== null ? parsed : null; } catch { - return null + return null; } - })() + })(); if (payload === null) { - return + return; } // Log the message type + correlation id only — never the body (PII). - logger.debug('iframe.message_received', { type: payload.type, request_id: payload.request_id }) + logger.debug("iframe.message_received", { + type: payload.type, + request_id: payload.request_id, + }); if (payload.type === EDITOR_READY_EVENT) { - markEditorReady('editor_ready_event') - channels.EDITOR_READY.emit({}) - return + markEditorReady("editor_ready_event"); + channels.EDITOR_READY.emit({}); + return; } if (payload.type === DOCUMENT_LOADED_EVENT) { - const rawDocId = payload.data?.document_id - if (typeof rawDocId === 'string' && rawDocId !== '') { + const rawDocId = payload.data?.document_id; + if (typeof rawDocId === "string" && rawDocId !== "") { // Forward every real DOCUMENT_LOADED verbatim (snake wire data). - channels.DOCUMENT_LOADED.emit({ document_id: rawDocId }) + channels.DOCUMENT_LOADED.emit({ document_id: rawDocId }); if (documentId !== rawDocId) { - documentId = rawDocId - if (state.kind === 'documentLoaded') { + documentId = rawDocId; + if (state.kind === "documentLoaded") { // Late DOCUMENT_LOADED after the probe already flipped the state: still // refresh the internal documentId snapshot. - transitionTo({ kind: 'documentLoaded', documentId }) - return + transitionTo({ kind: "documentLoaded", documentId }); + return; } } } - markDocumentLoaded() - return + markDocumentLoaded(); + return; } if (payload.type === SUBMISSION_SENT_EVENT) { - const submission = asSubmissionSent(payload.data) + const submission = asSubmissionSent(payload.data); if (submission !== null) { - channels.SUBMISSION_SENT.emit(submission) + channels.SUBMISSION_SENT.emit(submission); } - return + return; } if (payload.type === PAGE_FOCUSED_EVENT) { - const pageFocused = asPageFocused(payload.data) + const pageFocused = asPageFocused(payload.data); if (pageFocused !== null) { - channels.PAGE_FOCUSED.emit(pageFocused) + channels.PAGE_FOCUSED.emit(pageFocused); } - return + return; } if (payload.type !== REQUEST_RESULT_TYPE) { - return + return; } - const requestId = typeof payload.data?.request_id === 'string' ? payload.data.request_id : null + const requestId = + typeof payload.data?.request_id === "string" + ? payload.data.request_id + : null; if (requestId === null) { - return + return; } if (probeRequestIds.has(requestId)) { - probeRequestIds.delete(requestId) - markEditorReady('probe') - const probeResult = payload.data?.result + probeRequestIds.delete(requestId); + markEditorReady("probe"); + const probeResult = payload.data?.result; if (isBridgeResultLike(probeResult) && probeResult.success === true) { - markDocumentLoaded() + markDocumentLoaded(); } - return + return; } - const entry = pending.get(requestId) + const entry = pending.get(requestId); if (entry === undefined) { // Not a tracked request: a late/stale reply, or a probe reply received after // the probe loop was torn down. Not an error. - logger.debug('iframe.request_unmatched', { request_id: requestId }) - return + logger.debug("iframe.request_unmatched", { request_id: requestId }); + return; } - pending.delete(requestId) - clearTimeout(entry.timeoutId) - const rawResult = payload.data?.result + pending.delete(requestId); + clearTimeout(entry.timeoutId); + const rawResult = payload.data?.result; const result: BridgeResult = ((): BridgeResult => { if (!isBridgeResultLike(rawResult)) { return { success: false, - error: { code: 'unexpected:malformed_result', message: 'REQUEST_RESULT payload had no valid result' }, - } + error: { + code: "unexpected:malformed_result", + message: "REQUEST_RESULT payload had no valid result", + }, + }; } // Editor void ops emit `{ success: true }` without `data`. Normalize to // `{ success: true, data: null }` so consumers never see the missing field. - if (rawResult.success === true && !('data' in rawResult)) { - return { success: true, data: null } + if (rawResult.success === true && !("data" in rawResult)) { + return { success: true, data: null }; } switch (entry.resultShape) { - case 'wire': - return rawResult - case 'sdk': { + case "wire": + return rawResult; + case "sdk": { // wire (snake_case) -> SDK (camelCase) for the data / error.details payloads. // The transform preserves the envelope shape, so re-narrowing with the same // guard re-types it without a cast. - const camelCased = fromWireData(rawResult) + const camelCased = fromWireData(rawResult); if (!isBridgeResultLike(camelCased)) { return { success: false, - error: { code: 'unexpected:malformed_result', message: 'REQUEST_RESULT payload had no valid result' }, - } + error: { + code: "unexpected:malformed_result", + message: "REQUEST_RESULT payload had no valid result", + }, + }; } - return camelCased + return camelCased; } default: - entry.resultShape satisfies never - return rawResult + entry.resultShape satisfies never; + return rawResult; } - })() - logger.info('iframe.request_received', { + })(); + logger.info("iframe.request_received", { request_id: requestId, type: entry.wireType, elapsed_ms: Date.now() - entry.startedAtMs, success: result.success, - }) - entry.resolve(result) - } + }); + entry.resolve(result); + }; - window.addEventListener('message', onMessage) + window.addEventListener("message", onMessage); // The bridge is a thin interface, not a policy engine: the editor owns FIFO // ordering, input validation, and typed errors, and always replies. Each method // posts the request and correlates the reply; no client-side validation. const methods = { - createField: (input) => sendRequest('CREATE_FIELD', input), + batchSetFieldValue: (input) => sendRequest("BATCH_SET_FIELD_VALUE", input), + createField: (input) => sendRequest("CREATE_FIELD", input), // All-optional input: deleteFields() with no args clears every field. - deleteFields: (input) => sendRequest('DELETE_FIELDS', input ?? {}), - deletePages: (input) => sendRequest('DELETE_PAGES', input), - detectFields: () => sendRequest('DETECT_FIELDS', {}), - download: () => sendRequest('DOWNLOAD', {}), - focusField: (input) => sendRequest('FOCUS_FIELD', input), - getAnnotatedPage: (input) => sendRequest('GET_ANNOTATED_PAGE', input), - getDocumentContent: (input) => sendRequest('GET_DOCUMENT_CONTENT', input ?? {}), - getFields: () => sendRequest('GET_FIELDS', {}), - goTo: (input) => sendRequest('GO_TO', input), - loadDocument: (input) => sendRequest('LOAD_DOCUMENT', input), - movePage: (input) => sendRequest('MOVE_PAGE', input), - rotatePage: (input) => sendRequest('ROTATE_PAGE', input), - selectTool: (input) => sendRequest('SELECT_TOOL', input), - setFieldValue: (input) => sendRequest('SET_FIELD_VALUE', input), - submit: (input) => sendRequest('SUBMIT', input), - } satisfies IframeActions + deleteFields: (input) => sendRequest("DELETE_FIELDS", input ?? {}), + deletePages: (input) => sendRequest("DELETE_PAGES", input), + detectFields: () => sendRequest("DETECT_FIELDS", {}), + download: () => sendRequest("DOWNLOAD", {}), + focusField: (input) => sendRequest("FOCUS_FIELD", input), + getAnnotatedPage: (input) => sendRequest("GET_ANNOTATED_PAGE", input), + getDocumentContent: (input) => + sendRequest("GET_DOCUMENT_CONTENT", input ?? {}), + getFields: () => sendRequest("GET_FIELDS", {}), + goTo: (input) => sendRequest("GO_TO", input), + loadDocument: (input) => sendRequest("LOAD_DOCUMENT", input), + movePage: (input) => sendRequest("MOVE_PAGE", input), + rotatePage: (input) => sendRequest("ROTATE_PAGE", input), + selectTool: (input) => sendRequest("SELECT_TOOL", input), + setFieldValue: (input) => sendRequest("SET_FIELD_VALUE", input), + submit: (input) => sendRequest("SUBMIT", input), + } satisfies IframeActions; const dispose = (): void => { if (disposed) { - return + return; } - disposed = true - webMCPController.abort() - window.removeEventListener('message', onMessage) - clearReadyTimeout() - stopProbing() + disposed = true; + webMCPController.abort(); + window.removeEventListener("message", onMessage); + clearReadyTimeout(); + stopProbing(); for (const { timeoutId, resolve } of pending.values()) { - clearTimeout(timeoutId) + clearTimeout(timeoutId); resolve({ success: false, - error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, - }) + error: { + code: "unexpected:bridge_disposed", + message: "Editor bridge was disposed", + }, + }); } - pending.clear() + pending.clear(); for (const channel of Object.values(channels)) { - channel.clear() + channel.clear(); } - onDispose?.() - } + onDispose?.(); + }; const embed: Embed = { actions: methods, events: { on }, lifecycle: { dispose }, - } - return embed -} + }; + return embed; +}; diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index c136e428..6e2bbd4c 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -5,7 +5,7 @@ import type { METHOD_NAMES } from './method-names' export const LOCALES = ["fr", "en", "it", "de", "pt", "es", "ja", "nl"] as const export type Locale = (typeof LOCALES)[number] -export const EDITOR_ERROR_CODES = ["bad_request:download_blocked", "bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:failed_to_load_document", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"] as const +export const EDITOR_ERROR_CODES = ["bad_request:batch_validation_failed", "bad_request:download_blocked", "bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:failed_to_load_document", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"] as const export type EditorErrorCode = (typeof EDITOR_ERROR_CODES)[number] export const FIELD_TYPES = ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT", "DROPDOWN", "RADIO"] as const @@ -17,6 +17,8 @@ export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number] export const EXTRACTION_MODES = ["auto", "ocr"] as const export type ExtractionMode = (typeof EXTRACTION_MODES)[number] +export type BatchSetFieldValueInput = { updates: Array<{ fieldId: string; value: string | null }> } +export type BatchSetFieldValueOutput = null export type CreateFieldInput = { type: OverlayToolType; x: number; y: number; width: number; height: number; page: number; value?: string } export type CreateFieldOutput = { fieldId: string } export type DeleteFieldsInput = { fieldIds?: string[]; page?: number } @@ -53,7 +55,12 @@ export type SubmitOutput = null export type DocumentContentResult = GetDocumentContentOutput export type DocumentContentPage = GetDocumentContentOutput['pages'][number] +export type BatchValidationFailedDetails = { failures: Array<{ fieldId: string; code: "bad_request:field_not_found" | "bad_request:invalid_signature_url" | "bad_request:invalid_value" | "bad_request:read_only"; message: string }> } export type MissingRequiredFieldsDetails = { unfilledRequiredFieldsCount: number } +export type EditorErrorDetailsByCode = { + 'bad_request:batch_validation_failed': BatchValidationFailedDetails + 'bad_request:missing_required_fields': MissingRequiredFieldsDetails +} export type EditorReadyPayload = Record export type DocumentLoadedPayload = { document_id: string } @@ -61,6 +68,15 @@ export type PageFocusedPayload = { previous_page: number | null; current_page: n export type SubmissionSentPayload = { document_id: string; submission_id: string } export const OPERATIONS = [ + { + request_type: "BATCH_SET_FIELD_VALUE", + wire_type: "BATCH_SET_FIELD_VALUE", + method: "batchSetFieldValue", + description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", + error_codes: ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"] as const, + is_agentic_tool: true, + has_output: false, + } /* BatchSetFieldValue */, { request_type: "CREATE_FIELD", wire_type: "CREATE_FIELD", @@ -192,7 +208,7 @@ export const OPERATIONS = [ wire_type: "SET_FIELD_VALUE", method: "setFieldValue", description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.", - error_codes: ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"] as const, + error_codes: ["bad_request:field_not_found", "bad_request:invalid_signature_url", "bad_request:invalid_value", "bad_request:read_only", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: false, } /* SetFieldValue */, diff --git a/embed/src/generated/drift.ts b/embed/src/generated/drift.ts index 75cefc55..43b442c4 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -14,6 +14,7 @@ export type DriftGuards = [ AssertTrue>, AssertTrue>, AssertTrue>, + AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, diff --git a/embed/src/generated/method-names.ts b/embed/src/generated/method-names.ts index cd19a9a9..0bf21b5b 100644 --- a/embed/src/generated/method-names.ts +++ b/embed/src/generated/method-names.ts @@ -2,4 +2,4 @@ // The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list // without pulling the operations table into the zero-dep root; contract.ts derives // MethodName from this list. -export const METHOD_NAMES = ["createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getAnnotatedPage", "getDocumentContent", "getFields", "goTo", "loadDocument", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const +export const METHOD_NAMES = ["batchSetFieldValue", "createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getAnnotatedPage", "getDocumentContent", "getFields", "goTo", "loadDocument", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const diff --git a/embed/src/generated/schemas.ts b/embed/src/generated/schemas.ts index 194c51ec..6572f652 100644 --- a/embed/src/generated/schemas.ts +++ b/embed/src/generated/schemas.ts @@ -1,6 +1,13 @@ // AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. import { z } from 'zod' +export const BatchSetFieldValueInput = z.object({ + updates: z.array(z.object({ + fieldId: z.string().describe("ID of the field to update."), + value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), +})).describe("The fields to set, at least one, each field at most once. Fields not listed keep their value."), +}).describe("Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.") +export type BatchSetFieldValueInput = z.infer export const CreateFieldInput = z.object({ type: z.enum(["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]).describe("Field type to create."), x: z.number().describe("Field x position, in PDF points."), diff --git a/embed/src/generated/tools.ts b/embed/src/generated/tools.ts index 644e10e1..037f74bd 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -4,6 +4,7 @@ import * as Schemas from './schemas' // The agentic tool registry. Each tool name is the camelCase operation name; // load_document is excluded here (a host/setup action; the WebMCP surface registers it). export const TOOL_DEFINITIONS = { + batchSetFieldValue: { description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", inputSchema: Schemas.BatchSetFieldValueInput }, createField: { description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", inputSchema: Schemas.CreateFieldInput }, deleteFields: { description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", inputSchema: Schemas.DeleteFieldsInput }, deletePages: { description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.DeletePagesInput }, diff --git a/embed/src/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts index 3d4ff04e..5af96e5d 100644 --- a/embed/src/generated/webmcp-tools.ts +++ b/embed/src/generated/webmcp-tools.ts @@ -25,6 +25,7 @@ export type WebMCPToolRecord = { } export const WEBMCP_TOOLS = { + batchSetFieldValue: {"name":"simplepdf_embed_batch_set_field_value","description":"Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUE"}, createField: {"name":"simplepdf_embed_create_field","description":"Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, deleteFields: {"name":"simplepdf_embed_delete_fields","description":"Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, deletePages: {"name":"simplepdf_embed_delete_pages","description":"Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, diff --git a/embed/src/tools.ts b/embed/src/tools.ts index 9904a511..c50088f0 100644 --- a/embed/src/tools.ts +++ b/embed/src/tools.ts @@ -4,9 +4,10 @@ // system prompt, no middleware (host-owned). zod is a peer dependency (used to // validate agentic input before dispatch; the thin root bridge does not validate). -import type { z } from 'zod' -import { TOOL_DEFINITIONS, type SimplePDFToolName } from './generated/tools' +import type { z } from "zod"; +import { TOOL_DEFINITIONS, type SimplePDFToolName } from "./generated/tools"; import { + BatchSetFieldValueInput, CreateFieldInput, DeleteFieldsInput, DeletePagesInput, @@ -22,31 +23,38 @@ import { SelectToolInput, SetFieldValueInput, SubmitInput, -} from './generated/schemas' -import type { BridgeResult, IframeActions } from './types' +} from "./generated/schemas"; +import type { BridgeResult, IframeActions } from "./types"; -export { TOOL_DEFINITIONS as SIMPLEPDF_TOOLS } from './generated/tools' -export type { SimplePDFToolName } from './generated/tools' +export { TOOL_DEFINITIONS as SIMPLEPDF_TOOLS } from "./generated/tools"; +export type { SimplePDFToolName } from "./generated/tools"; -const AGENTIC_TOOL_NAMES: ReadonlySet = new Set(Object.keys(TOOL_DEFINITIONS)) +const AGENTIC_TOOL_NAMES: ReadonlySet = new Set( + Object.keys(TOOL_DEFINITIONS) +); -export const isSimplePDFToolName = (value: unknown): value is SimplePDFToolName => - typeof value === 'string' && AGENTIC_TOOL_NAMES.has(value) +export const isSimplePDFToolName = ( + value: unknown +): value is SimplePDFToolName => + typeof value === "string" && AGENTIC_TOOL_NAMES.has(value); const dispatch = ( schema: TSchema, input: unknown, - call: (value: z.infer) => Promise>, + call: (value: z.infer) => Promise> ): Promise> => { - const parsed = schema.safeParse(input) + const parsed = schema.safeParse(input); if (!parsed.success) { return Promise.resolve({ success: false, - error: { code: 'bad_request:invalid_input', message: parsed.error.message }, - }) + error: { + code: "bad_request:invalid_input", + message: parsed.error.message, + }, + }); } - return call(parsed.data) -} + return call(parsed.data); +}; // Validate the agentic input against the tool schema, then call the matching // editor action. The switch is exhaustive over SimplePDFToolName: a new agentic @@ -55,49 +63,76 @@ const dispatch = ( export const routeToolCall = ( actions: IframeActions, toolName: SimplePDFToolName, - input: unknown, + input: unknown ): Promise> => { switch (toolName) { - case 'createField': - return dispatch(CreateFieldInput, input, (value) => actions.createField(value)) - case 'deleteFields': + case "createField": + return dispatch(CreateFieldInput, input, (value) => + actions.createField(value) + ); + case "deleteFields": // All-optional: coerce a missing payload to {} (delete every field) like the // other no-required-input tools, so the model can omit it. - return dispatch(DeleteFieldsInput, input ?? {}, (value) => actions.deleteFields(value)) - case 'deletePages': - return dispatch(DeletePagesInput, input, (value) => actions.deletePages(value)) + return dispatch(DeleteFieldsInput, input ?? {}, (value) => + actions.deleteFields(value) + ); + case "deletePages": + return dispatch(DeletePagesInput, input, (value) => + actions.deletePages(value) + ); // No-input + all-optional tools coerce a missing input to {} (the AI SDK // sends {} for them); required-input tools below keep `input` so a missing // payload fails validation. - case 'detectFields': - return dispatch(DetectFieldsInput, input ?? {}, () => actions.detectFields()) - case 'download': - return dispatch(DownloadInput, input ?? {}, () => actions.download()) - case 'focusField': - return dispatch(FocusFieldInput, input, (value) => actions.focusField(value)) - case 'getAnnotatedPage': - return dispatch(GetAnnotatedPageInput, input, (value) => actions.getAnnotatedPage(value)) - case 'getDocumentContent': - return dispatch(GetDocumentContentInput, input ?? {}, (value) => actions.getDocumentContent(value)) - case 'getFields': - return dispatch(GetFieldsInput, input ?? {}, () => actions.getFields()) - case 'goTo': - return dispatch(GoToInput, input, (value) => actions.goTo(value)) - case 'movePage': - return dispatch(MovePageInput, input, (value) => actions.movePage(value)) - case 'rotatePage': - return dispatch(RotatePageInput, input, (value) => actions.rotatePage(value)) - case 'selectTool': - return dispatch(SelectToolInput, input, (value) => actions.selectTool(value)) - case 'setFieldValue': - return dispatch(SetFieldValueInput, input, (value) => actions.setFieldValue(value)) - case 'submit': - return dispatch(SubmitInput, input, (value) => actions.submit(value)) + case "detectFields": + return dispatch(DetectFieldsInput, input ?? {}, () => + actions.detectFields() + ); + case "download": + return dispatch(DownloadInput, input ?? {}, () => actions.download()); + case "focusField": + return dispatch(FocusFieldInput, input, (value) => + actions.focusField(value) + ); + case "getAnnotatedPage": + return dispatch(GetAnnotatedPageInput, input, (value) => + actions.getAnnotatedPage(value) + ); + case "getDocumentContent": + return dispatch(GetDocumentContentInput, input ?? {}, (value) => + actions.getDocumentContent(value) + ); + case "getFields": + return dispatch(GetFieldsInput, input ?? {}, () => actions.getFields()); + case "goTo": + return dispatch(GoToInput, input, (value) => actions.goTo(value)); + case "movePage": + return dispatch(MovePageInput, input, (value) => actions.movePage(value)); + case "rotatePage": + return dispatch(RotatePageInput, input, (value) => + actions.rotatePage(value) + ); + case "selectTool": + return dispatch(SelectToolInput, input, (value) => + actions.selectTool(value) + ); + case "setFieldValue": + return dispatch(SetFieldValueInput, input, (value) => + actions.setFieldValue(value) + ); + case "batchSetFieldValue": + return dispatch(BatchSetFieldValueInput, input, (value) => + actions.batchSetFieldValue(value) + ); + case "submit": + return dispatch(SubmitInput, input, (value) => actions.submit(value)); default: - toolName satisfies never + toolName satisfies never; return Promise.resolve({ success: false, - error: { code: 'bad_request:invalid_input', message: `Unknown tool: ${String(toolName)}` }, - }) + error: { + code: "bad_request:invalid_input", + message: `Unknown tool: ${String(toolName)}`, + }, + }); } -} +}; diff --git a/embed/src/types.ts b/embed/src/types.ts index f9994f7e..126b66e1 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -4,6 +4,7 @@ // the single source of truth; nothing in this file restates a wire shape. import type { + BatchSetFieldValueInput, CreateFieldInput, CreateFieldOutput, DeleteFieldsInput, @@ -12,6 +13,7 @@ import type { DetectFieldsOutput, DocumentLoadedPayload, EditorErrorCode, + EditorErrorDetailsByCode, EditorReadyPayload, FocusFieldInput, FocusFieldOutput, @@ -22,7 +24,6 @@ import type { GetFieldsOutput, GoToInput, LoadDocumentInput, - MissingRequiredFieldsDetails, MovePageInput, PageFocusedPayload, RotatePageInput, @@ -30,9 +31,11 @@ import type { SetFieldValueInput, SubmissionSentPayload, SubmitInput, -} from './generated/contract' +} from "./generated/contract"; export type { + BatchSetFieldValueInput, + BatchValidationFailedDetails, CreateFieldInput, CreateFieldOutput, DeleteFieldsInput, @@ -66,11 +69,11 @@ export type { SetFieldValueInput, SubmissionSentPayload, SubmitInput, -} from './generated/contract' +} from "./generated/contract"; // A single field as reported by get_fields. Alias kept for ergonomics; the // shape is owned by the generated GetFieldsOutput. -export type FieldRecord = GetFieldsOutput['fields'][number] +export type FieldRecord = GetFieldsOutput["fields"][number]; // --- Error model ----------------------------------------------------------- @@ -79,31 +82,35 @@ export type FieldRecord = GetFieldsOutput['fields'][number] // `bad_request:invalid_input` is emitted by the /tools router when an agentic call // fails schema validation before dispatch (the thin root bridge does not validate). export type BridgeOwnedErrorCode = - | 'bad_request:invalid_input' - | 'unexpected:timeout' - | 'unexpected:iframe_not_mounted' - | 'unexpected:bridge_disposed' - | 'unexpected:malformed_result' - | 'unexpected:unknown' + | "bad_request:invalid_input" + | "unexpected:timeout" + | "unexpected:iframe_not_mounted" + | "unexpected:bridge_disposed" + | "unexpected:malformed_result" + | "unexpected:unknown"; // The complete, closed error union: package-owned transport codes UNION the // generated editor codes (the customer-facing set already redacted at the wire // boundary, where internal failures collapse to unexpected:internal_error). -export type BridgeErrorCode = BridgeOwnedErrorCode | EditorErrorCode +export type BridgeErrorCode = BridgeOwnedErrorCode | EditorErrorCode; + +// The codes of EditorErrorDetailsByCode carry typed details; every other code is a +// plain { code, message }. Discriminated on `code` (distributed per detailed code). +type DetailedBridgeError = + TCode extends keyof EditorErrorDetailsByCode + ? { code: TCode; message: string; details: EditorErrorDetailsByCode[TCode] } + : never; -// bad_request:missing_required_fields carries typed details; every other code -// is a plain { code, message }. Discriminated on `code`. export type BridgeError = + | DetailedBridgeError | { - code: 'bad_request:missing_required_fields' - message: string - details: MissingRequiredFieldsDetails - } - | { code: Exclude; message: string } + code: Exclude; + message: string; + }; export type BridgeResult = | { success: true; data: TData } - | { success: false; error: BridgeError } + | { success: false; error: BridgeError }; // --- Lifecycle ------------------------------------------------------------- @@ -112,21 +119,23 @@ export type BridgeResult = // (readiness probing + createEmbed's load-when-ready gate); not on the public handle — // readiness is observable via the EDITOR_READY / DOCUMENT_LOADED editor events. export type BridgeState = - | { kind: 'booting' } - | { kind: 'editorReady' } - | { kind: 'documentLoaded'; documentId: string | null } + | { kind: "booting" } + | { kind: "editorReady" } + | { kind: "documentLoaded"; documentId: string | null }; // The editor's outbound events, forwarded to onEmbedEvent VERBATIM: SCREAMING_SNAKE // `type` + snake_case `data` (the stable, established contract — deliberately NOT // camelCased, unlike op payloads). Every payload comes from the manifest `events`. export type EditorEvent = - | { type: 'EDITOR_READY'; data: EditorReadyPayload } - | { type: 'DOCUMENT_LOADED'; data: DocumentLoadedPayload } - | { type: 'PAGE_FOCUSED'; data: PageFocusedPayload } - | { type: 'SUBMISSION_SENT'; data: SubmissionSentPayload } + | { type: "EDITOR_READY"; data: EditorReadyPayload } + | { type: "DOCUMENT_LOADED"; data: DocumentLoadedPayload } + | { type: "PAGE_FOCUSED"; data: PageFocusedPayload } + | { type: "SUBMISSION_SENT"; data: SubmissionSentPayload }; // Event type -> payload, derived from EditorEvent, for the granular on(type, handler). -export type EditorEventMap = { [TEvent in EditorEvent as TEvent['type']]: TEvent['data'] } +export type EditorEventMap = { + [TEvent in EditorEvent as TEvent["type"]]: TEvent["data"]; +}; // --- Handle groups --------------------------------------------------------- @@ -136,23 +145,34 @@ export type EditorEventMap = { [TEvent in EditorEvent as TEvent['type']]: TEvent // throws. Drift-guarded against the generated operation set in ./generated/drift.ts // (a new operation fails the build until added here). export type IframeActions = { - createField: (input: CreateFieldInput) => Promise> - deleteFields: (input?: DeleteFieldsInput) => Promise> - deletePages: (input: DeletePagesInput) => Promise - detectFields: () => Promise> - download: () => Promise - focusField: (input: FocusFieldInput) => Promise> - getAnnotatedPage: (input: GetAnnotatedPageInput) => Promise> - getDocumentContent: (input?: GetDocumentContentInput) => Promise> - getFields: () => Promise> - goTo: (input: GoToInput) => Promise - loadDocument: (input: LoadDocumentInput) => Promise - movePage: (input: MovePageInput) => Promise - rotatePage: (input: RotatePageInput) => Promise - selectTool: (input: SelectToolInput) => Promise - setFieldValue: (input: SetFieldValueInput) => Promise - submit: (input: SubmitInput) => Promise -} + batchSetFieldValue: (input: BatchSetFieldValueInput) => Promise; + createField: ( + input: CreateFieldInput + ) => Promise>; + deleteFields: ( + input?: DeleteFieldsInput + ) => Promise>; + deletePages: (input: DeletePagesInput) => Promise; + detectFields: () => Promise>; + download: () => Promise; + focusField: ( + input: FocusFieldInput + ) => Promise>; + getAnnotatedPage: ( + input: GetAnnotatedPageInput + ) => Promise>; + getDocumentContent: ( + input?: GetDocumentContentInput + ) => Promise>; + getFields: () => Promise>; + goTo: (input: GoToInput) => Promise; + loadDocument: (input: LoadDocumentInput) => Promise; + movePage: (input: MovePageInput) => Promise; + rotatePage: (input: RotatePageInput) => Promise; + selectTool: (input: SelectToolInput) => Promise; + setFieldValue: (input: SetFieldValueInput) => Promise; + submit: (input: SubmitInput) => Promise; +}; // `embed.events` — granular, EventEmitter-style subscription to the editor's outbound // events. `on(type, handler)` hands the handler that event's typed payload and returns @@ -160,20 +180,20 @@ export type IframeActions = { export type EmbedEvents = { on: ( type: TEventType, - handler: (data: EditorEventMap[TEventType]) => void, - ) => () => void -} + handler: (data: EditorEventMap[TEventType]) => void + ) => () => void; +}; // `embed.lifecycle` — teardown. export type EmbedLifecycle = { - dispose: () => void -} + dispose: () => void; +}; // The handle returned by createEmbed: three groups — `actions`, `events`, `lifecycle`. // Convention: name the variable `embed` (e.g. `embed.actions.goTo({ page })`, // `embed.events.on('SUBMISSION_SENT', …)`, `embed.lifecycle.dispose()`). export type Embed = { - actions: IframeActions - events: EmbedEvents - lifecycle: EmbedLifecycle -} + actions: IframeActions; + events: EmbedEvents; + lifecycle: EmbedLifecycle; +}; diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 7c355e50..eadd7d00 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -11,99 +11,128 @@ // table it reads) is downloaded otherwise. // CF: https://webmachinelearning.github.io/webmcp/ -import type { MethodName, WireType } from './generated/contract' -import { METHOD_NAMES } from './generated/method-names' -import { WEBMCP_TOOLS, type WebMCPToolRecord } from './generated/webmcp-tools' -import type { BridgeLogger } from './logger' -import type { BridgeResult } from './types' -import { modelContextCandidates } from './webmcp-shared' +import type { MethodName, WireType } from "./generated/contract"; +import { METHOD_NAMES } from "./generated/method-names"; +import { WEBMCP_TOOLS, type WebMCPToolRecord } from "./generated/webmcp-tools"; +import type { BridgeLogger } from "./logger"; +import type { BridgeResult } from "./types"; +import { modelContextCandidates } from "./webmcp-shared"; // The MCP tool-result envelope. The specification serializes whatever `execute` // resolves with as JSON text; this shape is what runtimes that map results onto MCP's // CallToolResult read (and what the editor's own in-page tools return): a failed // Result additionally flagged `isError`, a page render carried as an `image` block. -type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: 'image/png' } -type CallToolResult = { content: ToolContent[]; isError?: boolean } -type WebMCPTool = Omit & { - execute: (input: unknown, options?: { signal: AbortSignal }) => Promise -} +type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: "image/png" }; +type CallToolResult = { content: ToolContent[]; isError?: boolean }; +type WebMCPTool = Omit & { + execute: ( + input: unknown, + options?: { signal: AbortSignal } + ) => Promise; +}; type ModelContext = { - registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown -} + registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown; +}; -const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; const isModelContext = (value: unknown): value is ModelContext => - typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' + typeof value === "object" && + value !== null && + "registerTool" in value && + typeof value.registerTool === "function"; -const readModelContext = (): ModelContext | null => modelContextCandidates().find(isModelContext) ?? null +const readModelContext = (): ModelContext | null => + modelContextCandidates().find(isModelContext) ?? null; const toTextToolResult = (result: BridgeResult): CallToolResult => ({ - content: [{ type: 'text', text: JSON.stringify(result) }], + content: [{ type: "text", text: JSON.stringify(result) }], ...(result.success ? {} : { isError: true }), -}) +}); // The render travels once, as the image block a vision-capable runtime shows the // model; the text block keeps the rest of the result (page, size, badges). Anything // but a successful PNG render takes the plain text envelope. -const toAnnotatedPageToolResult = (result: BridgeResult): CallToolResult => { +const toAnnotatedPageToolResult = ( + result: BridgeResult +): CallToolResult => { if (!result.success) { - return toTextToolResult(result) + return toTextToolResult(result); } - const render: unknown = result.data - if (typeof render !== 'object' || render === null || !('image_data_url' in render)) { - return toTextToolResult(result) + const render: unknown = result.data; + if ( + typeof render !== "object" || + render === null || + !("image_data_url" in render) + ) { + return toTextToolResult(result); } - const { image_data_url: imageDataUrl, ...renderWithoutImage } = render - if (typeof imageDataUrl !== 'string' || !imageDataUrl.startsWith(PNG_DATA_URL_PREFIX)) { - return toTextToolResult(result) + const { image_data_url: imageDataUrl, ...renderWithoutImage } = render; + if ( + typeof imageDataUrl !== "string" || + !imageDataUrl.startsWith(PNG_DATA_URL_PREFIX) + ) { + return toTextToolResult(result); } return { content: [ - { type: 'image', data: imageDataUrl.slice(PNG_DATA_URL_PREFIX.length), mimeType: 'image/png' }, - { type: 'text', text: JSON.stringify({ success: true, data: renderWithoutImage }) }, + { + type: "image", + data: imageDataUrl.slice(PNG_DATA_URL_PREFIX.length), + mimeType: "image/png", + }, + { + type: "text", + text: JSON.stringify({ success: true, data: renderWithoutImage }), + }, ], - } -} + }; +}; // One arm per operation on purpose: a new operation must decide here whether its result // is text or a picture instead of taking the text envelope unnoticed. -const toCallToolResult = (wireType: WireType, result: BridgeResult): CallToolResult => { +const toCallToolResult = ( + wireType: WireType, + result: BridgeResult +): CallToolResult => { switch (wireType) { - case 'GET_ANNOTATED_PAGE': - return toAnnotatedPageToolResult(result) - case 'CREATE_FIELD': - case 'DELETE_FIELDS': - case 'DELETE_PAGES': - case 'DETECT_FIELDS': - case 'DOWNLOAD': - case 'FOCUS_FIELD': - case 'GET_DOCUMENT_CONTENT': - case 'GET_FIELDS': - case 'GO_TO': - case 'LOAD_DOCUMENT': - case 'MOVE_PAGE': - case 'ROTATE_PAGE': - case 'SELECT_TOOL': - case 'SET_FIELD_VALUE': - case 'SUBMIT': - return toTextToolResult(result) + case "GET_ANNOTATED_PAGE": + return toAnnotatedPageToolResult(result); + case "BATCH_SET_FIELD_VALUE": + case "CREATE_FIELD": + case "DELETE_FIELDS": + case "DELETE_PAGES": + case "DETECT_FIELDS": + case "DOWNLOAD": + case "FOCUS_FIELD": + case "GET_DOCUMENT_CONTENT": + case "GET_FIELDS": + case "GO_TO": + case "LOAD_DOCUMENT": + case "MOVE_PAGE": + case "ROTATE_PAGE": + case "SELECT_TOOL": + case "SET_FIELD_VALUE": + case "SUBMIT": + return toTextToolResult(result); default: - wireType satisfies never - return toTextToolResult(result) + wireType satisfies never; + return toTextToolResult(result); } -} +}; // A model context is a page-level singleton keyed by tool name, so two embeds on one // page would collide; the first registration of a name wins and the rest are reported. // Each name records the signal that owns it, so only its owner ever frees it. -const liveTools = new Map() +const liveTools = new Map(); const freeTool = (name: string, owner: AbortSignal): void => { if (liveTools.get(name) === owner) { - liveTools.delete(name) + liveTools.delete(name); } -} +}; // Returns whether a usable model context was found (and the tools handed to it), so // the bridge can keep probing on later lifecycle transitions when it was not. @@ -115,28 +144,31 @@ export const registerWebMCPTools = ({ }: { // Resolves with the editor's wire-shaped Result (snake_case, what the record's // description promises), not the SDK's camelCased one. - dispatch: (wireType: WireType, data: unknown) => Promise> - exclude: readonly MethodName[] - signal: AbortSignal - logger: BridgeLogger + dispatch: ( + wireType: WireType, + data: unknown + ) => Promise>; + exclude: readonly MethodName[]; + signal: AbortSignal; + logger: BridgeLogger; }): boolean => { if (signal.aborted) { - return false + return false; } - const modelContext = readModelContext() + const modelContext = readModelContext(); if (modelContext === null) { - logger.info('webmcp.unavailable', { reason: 'invalid_model_context' }) - return false + logger.info("webmcp.unavailable", { reason: "invalid_model_context" }); + return false; } - const excluded = new Set(exclude) + const excluded = new Set(exclude); for (const method of METHOD_NAMES) { if (excluded.has(method)) { - continue + continue; } - const record = WEBMCP_TOOLS[method] + const record = WEBMCP_TOOLS[method]; if (liveTools.has(record.name)) { - logger.warn('webmcp.tool_already_registered', { tool: record.name }) - continue + logger.warn("webmcp.tool_already_registered", { tool: record.name }); + continue; } const tool: WebMCPTool = { name: record.name, @@ -147,25 +179,30 @@ export const registerWebMCPTools = ({ // was posted still runs there (the wire has no cancel frame). A nullish input becomes // an empty payload (the no-input operations' wire shape). execute: async (input, options) => { - options?.signal.throwIfAborted() - return toCallToolResult(record.wireType, await dispatch(record.wireType, input ?? {})) + options?.signal.throwIfAborted(); + return toCallToolResult( + record.wireType, + await dispatch(record.wireType, input ?? {}) + ); }, - } - liveTools.set(tool.name, signal) - signal.addEventListener('abort', () => freeTool(tool.name, signal), { once: true }) + }; + liveTools.set(tool.name, signal); + signal.addEventListener("abort", () => freeTool(tool.name, signal), { + once: true, + }); // Registration is best-effort: a runtime that rejects one tool must not take the // others down or escape as an unhandled rejection. void (async (): Promise => { try { - await modelContext.registerTool(tool, { signal }) + await modelContext.registerTool(tool, { signal }); } catch (error) { - freeTool(tool.name, signal) - logger.error('webmcp.register_tool_failed', { + freeTool(tool.name, signal); + logger.error("webmcp.register_tool_failed", { tool: tool.name, message: error instanceof Error ? error.message : String(error), - }) + }); } - })() + })(); } - return true -} + return true; +}; diff --git a/embed/test/bridge.test.ts b/embed/test/bridge.test.ts index 8de61194..fae9e696 100644 --- a/embed/test/bridge.test.ts +++ b/embed/test/bridge.test.ts @@ -1,473 +1,599 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { attachEmbed } from '../src/bridge' -import type { BridgeState, Embed } from '../src/types' +import { afterEach, describe, expect, it, vi } from "vitest"; +import { attachEmbed } from "../src/bridge"; +import type { BridgeState, Embed } from "../src/types"; -const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' +const EDITOR_ORIGIN = "https://tenant.simplepdf.com"; -type Posted = { type: string; request_id: string; data: unknown } +type Posted = { type: string; request_id: string; data: unknown }; type Harness = { - embed: Embed - posted: Posted[] - reply: (message: unknown) => void - lastRequestId: () => string - contentWindow: Window + embed: Embed; + posted: Posted[]; + reply: (message: unknown) => void; + lastRequestId: () => string; + contentWindow: Window; // The lifecycle state machine is internal (no public getState); tests observe it // through onStateChange, the same hook createEmbed's load-when-ready gate uses. - getState: () => BridgeState -} + getState: () => BridgeState; +}; -const harnesses: Harness[] = [] +const harnesses: Harness[] = []; const makeHarness = (): Harness => { - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const contentWindow = iframe.contentWindow + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const contentWindow = iframe.contentWindow; if (contentWindow === null) { - throw new Error('jsdom iframe has no contentWindow') + throw new Error("jsdom iframe has no contentWindow"); } - const posted: Posted[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const posted: Posted[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) - const states: BridgeState[] = [{ kind: 'booting' }] + ); + const states: BridgeState[] = [{ kind: "booting" }]; const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, onStateChange: (state) => states.push(state), - }) + }); const reply = (message: unknown): void => { window.dispatchEvent( - new MessageEvent('message', { data: JSON.stringify(message), origin: EDITOR_ORIGIN, source: contentWindow }), - ) - } + new MessageEvent("message", { + data: JSON.stringify(message), + origin: EDITOR_ORIGIN, + source: contentWindow, + }) + ); + }; const lastRequestId = (): string => { - const last = posted[posted.length - 1] + const last = posted[posted.length - 1]; if (last === undefined) { - throw new Error('no message posted') + throw new Error("no message posted"); } - return last.request_id - } + return last.request_id; + }; const getState = (): BridgeState => { - const latest = states[states.length - 1] + const latest = states[states.length - 1]; if (latest === undefined) { - throw new Error('no state captured') + throw new Error("no state captured"); } - return latest - } - const harness: Harness = { embed, posted, reply, lastRequestId, contentWindow, getState } - harnesses.push(harness) - return harness -} + return latest; + }; + const harness: Harness = { + embed, + posted, + reply, + lastRequestId, + contentWindow, + getState, + }; + harnesses.push(harness); + return harness; +}; -const replyResult = (harness: Harness, requestId: string, result: unknown): void => - harness.reply({ type: 'REQUEST_RESULT', data: { request_id: requestId, result } }) +const replyResult = ( + harness: Harness, + requestId: string, + result: unknown +): void => + harness.reply({ + type: "REQUEST_RESULT", + data: { request_id: requestId, result }, + }); describe(attachEmbed.name, () => { afterEach(() => { for (const harness of harnesses) { - harness.embed.lifecycle.dispose() + harness.embed.lifecycle.dispose(); } - harnesses.length = 0 - document.body.innerHTML = '' - vi.restoreAllMocks() - vi.useRealTimers() - }) + harnesses.length = 0; + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.useRealTimers(); + }); - it('posts the SCREAMING_SNAKE wire type and correlates the reply by request_id', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.getFields() - const request = harness.posted[harness.posted.length - 1] - expect(request?.type).toBe('GET_FIELDS') - replyResult(harness, harness.lastRequestId(), { success: true, data: { fields: [] } }) - await expect(promise).resolves.toEqual({ success: true, data: { fields: [] } }) - }) + it("posts the SCREAMING_SNAKE wire type and correlates the reply by request_id", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.getFields(); + const request = harness.posted[harness.posted.length - 1]; + expect(request?.type).toBe("GET_FIELDS"); + replyResult(harness, harness.lastRequestId(), { + success: true, + data: { fields: [] }, + }); + await expect(promise).resolves.toEqual({ + success: true, + data: { fields: [] }, + }); + }); - it('normalizes a void success ({ success: true }) to { success: true, data: null }', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.goTo({ page: 2 }) - replyResult(harness, harness.lastRequestId(), { success: true }) - await expect(promise).resolves.toEqual({ success: true, data: null }) - }) + it("normalizes a void success ({ success: true }) to { success: true, data: null }", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.goTo({ page: 2 }); + replyResult(harness, harness.lastRequestId(), { success: true }); + await expect(promise).resolves.toEqual({ success: true, data: null }); + }); - it('forwards a typed editor error and camelCases its details payload', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.submit({ downloadCopy: false }) + it("forwards a typed editor error and camelCases its details payload", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.submit({ downloadCopy: false }); // The wire sends snake_case details; the SDK surfaces them camelCased. replyResult(harness, harness.lastRequestId(), { success: false, error: { - code: 'bad_request:missing_required_fields', - message: '2 required fields are unfilled', + code: "bad_request:missing_required_fields", + message: "2 required fields are unfilled", details: { unfilled_required_fields_count: 2 }, }, - }) - const result = await promise - expect(result.success).toBe(false) - if (!result.success && result.error.code === 'bad_request:missing_required_fields') { - expect(result.error.details).toEqual({ unfilledRequiredFieldsCount: 2 }) + }); + const result = await promise; + expect(result.success).toBe(false); + if ( + !result.success && + result.error.code === "bad_request:missing_required_fields" + ) { + expect(result.error.details).toEqual({ unfilledRequiredFieldsCount: 2 }); } - }) + }); - it('transforms the camelCase request payload to snake_case on the wire', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.setFieldValue({ fieldId: 'f1', value: 'hello_world' }) - const request = harness.posted[harness.posted.length - 1] - expect(request?.type).toBe('SET_FIELD_VALUE') + it("transforms the camelCase request payload to snake_case on the wire", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.setFieldValue({ + fieldId: "f1", + value: "hello_world", + }); + const request = harness.posted[harness.posted.length - 1]; + expect(request?.type).toBe("SET_FIELD_VALUE"); // KEY fieldId -> field_id; the opaque value (with underscores) is untouched. - expect(request?.data).toEqual({ field_id: 'f1', value: 'hello_world' }) - replyResult(harness, harness.lastRequestId(), { success: true }) - await expect(promise).resolves.toEqual({ success: true, data: null }) - }) + expect(request?.data).toEqual({ field_id: "f1", value: "hello_world" }); + replyResult(harness, harness.lastRequestId(), { success: true }); + await expect(promise).resolves.toEqual({ success: true, data: null }); + }); - it('returns unexpected:iframe_not_mounted when the iframe is gone', async () => { - const embed = attachEmbed({ getIframe: () => null, editorOrigin: EDITOR_ORIGIN }) - const result = await embed.actions.getFields() + it("returns unexpected:iframe_not_mounted when the iframe is gone", async () => { + const embed = attachEmbed({ + getIframe: () => null, + editorOrigin: EDITOR_ORIGIN, + }); + const result = await embed.actions.getFields(); expect(result).toEqual({ success: false, - error: { code: 'unexpected:iframe_not_mounted', message: 'Editor iframe is not mounted' }, - }) - embed.lifecycle.dispose() - }) + error: { + code: "unexpected:iframe_not_mounted", + message: "Editor iframe is not mounted", + }, + }); + embed.lifecycle.dispose(); + }); - it('times out with unexpected:timeout after the generous dead-iframe budget', async () => { - vi.useFakeTimers() - const harness = makeHarness() - const promise = harness.embed.actions.getFields() - await vi.advanceTimersByTimeAsync(60_001) - const result = await promise - expect(result.success).toBe(false) + it("times out with unexpected:timeout after the generous dead-iframe budget", async () => { + vi.useFakeTimers(); + const harness = makeHarness(); + const promise = harness.embed.actions.getFields(); + await vi.advanceTimersByTimeAsync(60_001); + const result = await promise; + expect(result.success).toBe(false); if (!result.success) { - expect(result.error.code).toBe('unexpected:timeout') + expect(result.error.code).toBe("unexpected:timeout"); } - }) + }); - it('does not starve a request queued behind a slow one (resolves when replied within budget)', async () => { - vi.useFakeTimers() - const harness = makeHarness() - const slow = harness.embed.actions.detectFields() - const slowId = harness.lastRequestId() - const fast = harness.embed.actions.getFields() - const fastId = harness.lastRequestId() + it("does not starve a request queued behind a slow one (resolves when replied within budget)", async () => { + vi.useFakeTimers(); + const harness = makeHarness(); + const slow = harness.embed.actions.detectFields(); + const slowId = harness.lastRequestId(); + const fast = harness.embed.actions.getFields(); + const fastId = harness.lastRequestId(); // 30s elapses (well under the 60s budget); the slow op is still in flight. - await vi.advanceTimersByTimeAsync(30_000) - replyResult(harness, fastId, { success: true, data: { fields: [] } }) - await expect(fast).resolves.toEqual({ success: true, data: { fields: [] } }) - replyResult(harness, slowId, { success: true, data: { detected_count: 1 } }) - await expect(slow).resolves.toEqual({ success: true, data: { detectedCount: 1 } }) - }) + await vi.advanceTimersByTimeAsync(30_000); + replyResult(harness, fastId, { success: true, data: { fields: [] } }); + await expect(fast).resolves.toEqual({ + success: true, + data: { fields: [] }, + }); + replyResult(harness, slowId, { + success: true, + data: { detected_count: 1 }, + }); + await expect(slow).resolves.toEqual({ + success: true, + data: { detectedCount: 1 }, + }); + }); - it('rejects pending requests with unexpected:bridge_disposed on dispose', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.getFields() - harness.embed.lifecycle.dispose() + it("rejects pending requests with unexpected:bridge_disposed on dispose", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.getFields(); + harness.embed.lifecycle.dispose(); await expect(promise).resolves.toEqual({ success: false, - error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, - }) - }) + error: { + code: "unexpected:bridge_disposed", + message: "Editor bridge was disposed", + }, + }); + }); - it('returns unexpected:bridge_disposed for a call made after dispose (without posting)', async () => { - const harness = makeHarness() - harness.embed.lifecycle.dispose() - const postedBefore = harness.posted.length - const result = await harness.embed.actions.getFields() + it("returns unexpected:bridge_disposed for a call made after dispose (without posting)", async () => { + const harness = makeHarness(); + harness.embed.lifecycle.dispose(); + const postedBefore = harness.posted.length; + const result = await harness.embed.actions.getFields(); expect(result).toEqual({ success: false, - error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, - }) - expect(harness.posted.length).toBe(postedBefore) - }) + error: { + code: "unexpected:bridge_disposed", + message: "Editor bridge was disposed", + }, + }); + expect(harness.posted.length).toBe(postedBefore); + }); - it('returns unexpected:unknown when posting throws (dead contentWindow)', async () => { - const harness = makeHarness() - vi.spyOn(harness.contentWindow, 'postMessage').mockImplementation(() => { - throw new Error('dead window') - }) - const result = await harness.embed.actions.getFields() - expect(result.success).toBe(false) + it("returns unexpected:unknown when posting throws (dead contentWindow)", async () => { + const harness = makeHarness(); + vi.spyOn(harness.contentWindow, "postMessage").mockImplementation(() => { + throw new Error("dead window"); + }); + const result = await harness.embed.actions.getFields(); + expect(result.success).toBe(false); if (!result.success) { - expect(result.error.code).toBe('unexpected:unknown') + expect(result.error.code).toBe("unexpected:unknown"); } - }) + }); - it('returns unexpected:malformed_result when the editor reply has no valid result', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.getFields() - replyResult(harness, harness.lastRequestId(), { nonsense: true }) - const result = await promise - expect(result.success).toBe(false) + it("returns unexpected:malformed_result when the editor reply has no valid result", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.getFields(); + replyResult(harness, harness.lastRequestId(), { nonsense: true }); + const result = await promise; + expect(result.success).toBe(false); if (!result.success) { - expect(result.error.code).toBe('unexpected:malformed_result') + expect(result.error.code).toBe("unexpected:malformed_result"); } - }) + }); - it('delivers SUBMISSION_SENT to on(type) with the verbatim snake_case payload', () => { - const harness = makeHarness() - const listener = vi.fn() - harness.embed.events.on('SUBMISSION_SENT', listener) - harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1', submission_id: 'sub_1' } }) - expect(listener).toHaveBeenCalledWith({ document_id: 'doc_1', submission_id: 'sub_1' }) - }) + it("delivers SUBMISSION_SENT to on(type) with the verbatim snake_case payload", () => { + const harness = makeHarness(); + const listener = vi.fn(); + harness.embed.events.on("SUBMISSION_SENT", listener); + harness.reply({ + type: "SUBMISSION_SENT", + data: { document_id: "doc_1", submission_id: "sub_1" }, + }); + expect(listener).toHaveBeenCalledWith({ + document_id: "doc_1", + submission_id: "sub_1", + }); + }); - it('delivers the EDITOR_READY and DOCUMENT_LOADED lifecycle events to on(type)', () => { - const harness = makeHarness() - const onReady = vi.fn() - const onLoaded = vi.fn() - harness.embed.events.on('EDITOR_READY', onReady) - harness.embed.events.on('DOCUMENT_LOADED', onLoaded) - harness.reply({ type: 'EDITOR_READY', data: {} }) - expect(onReady).toHaveBeenCalledWith({}) - harness.reply({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc_9' } }) - expect(onLoaded).toHaveBeenCalledWith({ document_id: 'doc_9' }) - }) + it("delivers the EDITOR_READY and DOCUMENT_LOADED lifecycle events to on(type)", () => { + const harness = makeHarness(); + const onReady = vi.fn(); + const onLoaded = vi.fn(); + harness.embed.events.on("EDITOR_READY", onReady); + harness.embed.events.on("DOCUMENT_LOADED", onLoaded); + harness.reply({ type: "EDITOR_READY", data: {} }); + expect(onReady).toHaveBeenCalledWith({}); + harness.reply({ type: "DOCUMENT_LOADED", data: { document_id: "doc_9" } }); + expect(onLoaded).toHaveBeenCalledWith({ document_id: "doc_9" }); + }); - it('keeps notifying handlers of the same event when one throws', () => { - const harness = makeHarness() - const second = vi.fn() - harness.embed.events.on('SUBMISSION_SENT', () => { - throw new Error('listener boom') - }) - harness.embed.events.on('SUBMISSION_SENT', second) - harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1', submission_id: 'sub_1' } }) - expect(second).toHaveBeenCalledWith({ document_id: 'doc_1', submission_id: 'sub_1' }) - }) + it("keeps notifying handlers of the same event when one throws", () => { + const harness = makeHarness(); + const second = vi.fn(); + harness.embed.events.on("SUBMISSION_SENT", () => { + throw new Error("listener boom"); + }); + harness.embed.events.on("SUBMISSION_SENT", second); + harness.reply({ + type: "SUBMISSION_SENT", + data: { document_id: "doc_1", submission_id: "sub_1" }, + }); + expect(second).toHaveBeenCalledWith({ + document_id: "doc_1", + submission_id: "sub_1", + }); + }); - it('drops a malformed SUBMISSION_SENT payload', () => { - const harness = makeHarness() - const listener = vi.fn() - harness.embed.events.on('SUBMISSION_SENT', listener) - harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1' } }) - expect(listener).not.toHaveBeenCalled() - }) + it("drops a malformed SUBMISSION_SENT payload", () => { + const harness = makeHarness(); + const listener = vi.fn(); + harness.embed.events.on("SUBMISSION_SENT", listener); + harness.reply({ type: "SUBMISSION_SENT", data: { document_id: "doc_1" } }); + expect(listener).not.toHaveBeenCalled(); + }); - it('unsubscribes when the returned disposer is called', () => { - const harness = makeHarness() - const listener = vi.fn() - const off = harness.embed.events.on('SUBMISSION_SENT', listener) - off() - harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1', submission_id: 'sub_1' } }) - expect(listener).not.toHaveBeenCalled() - }) + it("unsubscribes when the returned disposer is called", () => { + const harness = makeHarness(); + const listener = vi.fn(); + const off = harness.embed.events.on("SUBMISSION_SENT", listener); + off(); + harness.reply({ + type: "SUBMISSION_SENT", + data: { document_id: "doc_1", submission_id: "sub_1" }, + }); + expect(listener).not.toHaveBeenCalled(); + }); - it('delivers PAGE_FOCUSED to on(type) verbatim (null previous_page allowed)', () => { - const harness = makeHarness() - const listener = vi.fn() - harness.embed.events.on('PAGE_FOCUSED', listener) - harness.reply({ type: 'PAGE_FOCUSED', data: { previous_page: null, current_page: 1, total_pages: 3 } }) - expect(listener).toHaveBeenCalledWith({ previous_page: null, current_page: 1, total_pages: 3 }) - }) + it("delivers PAGE_FOCUSED to on(type) verbatim (null previous_page allowed)", () => { + const harness = makeHarness(); + const listener = vi.fn(); + harness.embed.events.on("PAGE_FOCUSED", listener); + harness.reply({ + type: "PAGE_FOCUSED", + data: { previous_page: null, current_page: 1, total_pages: 3 }, + }); + expect(listener).toHaveBeenCalledWith({ + previous_page: null, + current_page: 1, + total_pages: 3, + }); + }); - it('flips to editorReady then documentLoaded from probe responses', () => { - const harness = makeHarness() - expect(harness.getState().kind).toBe('booting') - const probe = harness.posted.find((message) => message.type === 'GET_FIELDS') - expect(probe).toBeDefined() + it("flips to editorReady then documentLoaded from probe responses", () => { + const harness = makeHarness(); + expect(harness.getState().kind).toBe("booting"); + const probe = harness.posted.find( + (message) => message.type === "GET_FIELDS" + ); + expect(probe).toBeDefined(); if (probe !== undefined) { - replyResult(harness, probe.request_id, { success: true, data: { fields: [] } }) + replyResult(harness, probe.request_id, { + success: true, + data: { fields: [] }, + }); } - expect(harness.getState().kind).toBe('documentLoaded') - }) + expect(harness.getState().kind).toBe("documentLoaded"); + }); - it('reports readiness via onStateChange when reached only through the probe (no editor event)', () => { + it("reports readiness via onStateChange when reached only through the probe (no editor event)", () => { // This is what drives createEmbed's "load the document once ready" gate: readiness // reached via the probe emits NO editor event, so onStateChange must still fire. - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const contentWindow = iframe.contentWindow + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const contentWindow = iframe.contentWindow; if (contentWindow === null) { - throw new Error('jsdom iframe has no contentWindow') + throw new Error("jsdom iframe has no contentWindow"); } - const posted: Posted[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const posted: Posted[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) - const states: string[] = [] + ); + const states: string[] = []; const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, onStateChange: (state) => states.push(state.kind), - }) - const probe = posted.find((message) => message.type === 'GET_FIELDS') + }); + const probe = posted.find((message) => message.type === "GET_FIELDS"); if (probe !== undefined) { window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: probe.request_id, result: { success: true, data: { fields: [] } } }, + type: "REQUEST_RESULT", + data: { + request_id: probe.request_id, + result: { success: true, data: { fields: [] } }, + }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }), - ) + }) + ); } - expect(states).toContain('documentLoaded') - embed.lifecycle.dispose() - }) + expect(states).toContain("documentLoaded"); + embed.lifecycle.dispose(); + }); - it('clears the readiness fallback once a document is loaded (no late warning or corruption)', () => { - vi.useFakeTimers() - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const contentWindow = iframe.contentWindow + it("clears the readiness fallback once a document is loaded (no late warning or corruption)", () => { + vi.useFakeTimers(); + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const contentWindow = iframe.contentWindow; if (contentWindow === null) { - throw new Error('jsdom iframe has no contentWindow') + throw new Error("jsdom iframe has no contentWindow"); } - const posted: Posted[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const posted: Posted[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) - const warn = vi.fn() - const states: string[] = [] + ); + const warn = vi.fn(); + const states: string[] = []; const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, logger: { debug: () => {}, info: () => {}, warn, error: () => {} }, onStateChange: (state) => states.push(state.kind), - }) - const probe = posted.find((message) => message.type === 'GET_FIELDS') + }); + const probe = posted.find((message) => message.type === "GET_FIELDS"); if (probe !== undefined) { window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: probe.request_id, result: { success: true, data: { fields: [] } } }, + type: "REQUEST_RESULT", + data: { + request_id: probe.request_id, + result: { success: true, data: { fields: [] } }, + }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }), - ) + }) + ); } - expect(states.at(-1)).toBe('documentLoaded') + expect(states.at(-1)).toBe("documentLoaded"); // The fallback timer was cleared on load: advancing past it neither warns nor // drops the loaded document back to editorReady. - vi.advanceTimersByTime(31_000) - expect(states.at(-1)).toBe('documentLoaded') - expect(warn).not.toHaveBeenCalled() - embed.lifecycle.dispose() - }) + vi.advanceTimersByTime(31_000); + expect(states.at(-1)).toBe("documentLoaded"); + expect(warn).not.toHaveBeenCalled(); + embed.lifecycle.dispose(); + }); - it('stops the readiness probe loop after the fallback window (bounded probing)', () => { - vi.useFakeTimers() - const harness = makeHarness() + it("stops the readiness probe loop after the fallback window (bounded probing)", () => { + vi.useFakeTimers(); + const harness = makeHarness(); // The editor never confirms readiness; probes fire every 500ms until the // 30s fallback, then stop. - vi.advanceTimersByTime(30_000) - const countAtFallback = harness.posted.filter((message) => message.type === 'GET_FIELDS').length - expect(countAtFallback).toBeGreaterThan(1) - vi.advanceTimersByTime(30_000) - const countLater = harness.posted.filter((message) => message.type === 'GET_FIELDS').length - expect(countLater).toBe(countAtFallback) - expect(harness.getState().kind).toBe('editorReady') - }) + vi.advanceTimersByTime(30_000); + const countAtFallback = harness.posted.filter( + (message) => message.type === "GET_FIELDS" + ).length; + expect(countAtFallback).toBeGreaterThan(1); + vi.advanceTimersByTime(30_000); + const countLater = harness.posted.filter( + (message) => message.type === "GET_FIELDS" + ).length; + expect(countLater).toBe(countAtFallback); + expect(harness.getState().kind).toBe("editorReady"); + }); - it('a throwing logger never affects bridge behavior', async () => { - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const contentWindow = iframe.contentWindow + it("a throwing logger never affects bridge behavior", async () => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const contentWindow = iframe.contentWindow; if (contentWindow === null) { - throw new Error('jsdom iframe has no contentWindow') + throw new Error("jsdom iframe has no contentWindow"); } - const posted: Posted[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const posted: Posted[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) + ); const boom = () => { - throw new Error('log boom') - } - const throwingLogger = { debug: boom, info: boom, warn: boom, error: boom } - const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, logger: throwingLogger }) - const promise = embed.actions.getFields() - const requestId = posted[posted.length - 1]?.request_id ?? '' + throw new Error("log boom"); + }; + const throwingLogger = { debug: boom, info: boom, warn: boom, error: boom }; + const embed = attachEmbed({ + getIframe: () => iframe, + editorOrigin: EDITOR_ORIGIN, + logger: throwingLogger, + }); + const promise = embed.actions.getFields(); + const requestId = posted[posted.length - 1]?.request_id ?? ""; window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: requestId, result: { success: true, data: { fields: [] } } }, + type: "REQUEST_RESULT", + data: { + request_id: requestId, + result: { success: true, data: { fields: [] } }, + }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }), - ) - await expect(promise).resolves.toEqual({ success: true, data: { fields: [] } }) + }) + ); + await expect(promise).resolves.toEqual({ + success: true, + data: { fields: [] }, + }); // Disposal must not throw either. - expect(() => embed.lifecycle.dispose()).not.toThrow() - }) + expect(() => embed.lifecycle.dispose()).not.toThrow(); + }); - it('an async-rejecting logger never surfaces as an unhandled rejection', async () => { - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const contentWindow = iframe.contentWindow + it("an async-rejecting logger never surfaces as an unhandled rejection", async () => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const contentWindow = iframe.contentWindow; if (contentWindow === null) { - throw new Error('jsdom iframe has no contentWindow') + throw new Error("jsdom iframe has no contentWindow"); } - const posted: Posted[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const posted: Posted[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) - const unhandled = vi.fn() - process.on('unhandledRejection', unhandled) - const rejecting = () => Promise.reject(new Error('async log boom')) + ); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + const rejecting = () => Promise.reject(new Error("async log boom")); const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, - logger: { debug: rejecting, info: rejecting, warn: rejecting, error: rejecting }, - }) - const promise = embed.actions.getFields() - const requestId = posted[posted.length - 1]?.request_id ?? '' + logger: { + debug: rejecting, + info: rejecting, + warn: rejecting, + error: rejecting, + }, + }); + const promise = embed.actions.getFields(); + const requestId = posted[posted.length - 1]?.request_id ?? ""; window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: requestId, result: { success: true, data: { fields: [] } } }, + type: "REQUEST_RESULT", + data: { + request_id: requestId, + result: { success: true, data: { fields: [] } }, + }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }), - ) - await expect(promise).resolves.toEqual({ success: true, data: { fields: [] } }) + }) + ); + await expect(promise).resolves.toEqual({ + success: true, + data: { fields: [] }, + }); // Let any microtask-queued rejections flush. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(unhandled).not.toHaveBeenCalled() - process.off('unhandledRejection', unhandled) - embed.lifecycle.dispose() - }) + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).not.toHaveBeenCalled(); + process.off("unhandledRejection", unhandled); + embed.lifecycle.dispose(); + }); - it('ignores messages from a foreign origin', async () => { - const harness = makeHarness() - const promise = harness.embed.actions.getFields() + it("ignores messages from a foreign origin", async () => { + const harness = makeHarness(); + const promise = harness.embed.actions.getFields(); window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: harness.lastRequestId(), result: { success: true, data: { fields: [] } } }, + type: "REQUEST_RESULT", + data: { + request_id: harness.lastRequestId(), + result: { success: true, data: { fields: [] } }, + }, }), - origin: 'https://evil.example.com', + origin: "https://evil.example.com", source: harness.contentWindow, - }), - ) + }) + ); // The foreign-origin reply is ignored; the request stays pending until disposed. - harness.embed.lifecycle.dispose() - const result = await promise - expect(result.success).toBe(false) - }) + harness.embed.lifecycle.dispose(); + const result = await promise; + expect(result.success).toBe(false); + }); - it('disposes idempotently, invoking onDispose exactly once', () => { - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const onDispose = vi.fn() - const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, onDispose }) - embed.lifecycle.dispose() - embed.lifecycle.dispose() - expect(onDispose).toHaveBeenCalledTimes(1) - }) -}) + it("disposes idempotently, invoking onDispose exactly once", () => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const onDispose = vi.fn(); + const embed = attachEmbed({ + getIframe: () => iframe, + editorOrigin: EDITOR_ORIGIN, + onDispose, + }); + embed.lifecycle.dispose(); + embed.lifecycle.dispose(); + expect(onDispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/embed/test/case-transform.test.ts b/embed/test/case-transform.test.ts index 6bc9cc26..bce2057c 100644 --- a/embed/test/case-transform.test.ts +++ b/embed/test/case-transform.test.ts @@ -1,56 +1,75 @@ -import { describe, expect, it } from 'vitest' -import { fromWireData, toWireData } from '../src/case-transform' - -describe('case-transform', () => { - it('camelCases wire keys deeply (nested objects + arrays)', () => { - expect(fromWireData({ fields: [{ field_id: 'f1', total_pages: 3 }] })).toEqual({ - fields: [{ fieldId: 'f1', totalPages: 3 }], - }) - }) - - it('snake_cases SDK keys for the wire', () => { - expect(toWireData({ fieldId: 'f1', downloadCopy: true, dataUrl: 'd' })).toEqual({ - field_id: 'f1', +import { describe, expect, it } from "vitest"; +import { fromWireData, toWireData } from "../src/case-transform"; + +describe("case-transform", () => { + it("camelCases wire keys deeply (nested objects + arrays)", () => { + expect( + fromWireData({ fields: [{ field_id: "f1", total_pages: 3 }] }) + ).toEqual({ + fields: [{ fieldId: "f1", totalPages: 3 }], + }); + }); + + it("snake_cases SDK keys for the wire", () => { + expect( + toWireData({ fieldId: "f1", downloadCopy: true, dataUrl: "d" }) + ).toEqual({ + field_id: "f1", download_copy: true, - data_url: 'd', - }) - }) - - it('round-trips SDK -> wire -> SDK', () => { - const sdk = { fieldIds: ['a', 'b'], extractionMode: 'plain', page: 2, value: null } - expect(fromWireData(toWireData(sdk))).toEqual(sdk) - }) - - it('transforms KEYS only — opaque values (even underscored) are untouched', () => { - expect(toWireData({ fieldId: 'f', value: 'a_snake_looking_value' })).toEqual({ - field_id: 'f', - value: 'a_snake_looking_value', - }) - expect(fromWireData({ field_id: 'f', value: 'leave_me_be' })).toEqual({ fieldId: 'f', value: 'leave_me_be' }) - }) - - it('passes primitives, null, and undefined through', () => { - expect(toWireData(null)).toBe(null) - expect(toWireData(undefined)).toBe(undefined) - expect(toWireData('x')).toBe('x') - expect(fromWireData(42)).toBe(42) - }) - - it('passes arrays of primitives through unchanged', () => { - expect(toWireData([1, 'a', true, null])).toEqual([1, 'a', true, null]) - }) - - it('does not flatten non-plain objects (Date) — passes the instance through', () => { - const when = new Date(0) + data_url: "d", + }); + }); + + it("round-trips SDK -> wire -> SDK", () => { + const sdk = { + fieldIds: ["a", "b"], + extractionMode: "plain", + page: 2, + value: null, + }; + expect(fromWireData(toWireData(sdk))).toEqual(sdk); + }); + + it("transforms KEYS only — opaque values (even underscored) are untouched", () => { + expect( + toWireData({ fieldId: "f", value: "a_snake_looking_value" }) + ).toEqual({ + field_id: "f", + value: "a_snake_looking_value", + }); + expect(fromWireData({ field_id: "f", value: "leave_me_be" })).toEqual({ + fieldId: "f", + value: "leave_me_be", + }); + }); + + it("passes primitives, null, and undefined through", () => { + expect(toWireData(null)).toBe(null); + expect(toWireData(undefined)).toBe(undefined); + expect(toWireData("x")).toBe("x"); + expect(fromWireData(42)).toBe(42); + }); + + it("passes arrays of primitives through unchanged", () => { + expect(toWireData([1, "a", true, null])).toEqual([1, "a", true, null]); + }); + + it("does not flatten non-plain objects (Date) — passes the instance through", () => { + const when = new Date(0); // A flattened Date would become {}, which would not deep-equal the Date. - expect(toWireData({ createdAt: when })).toEqual({ created_at: when }) - }) - - it('does not allow prototype pollution through a __proto__ key', () => { - const malicious: unknown = JSON.parse('{"__proto__": {"polluted": true}, "fieldId": "f1"}') - const out = toWireData(malicious) - const polluted: unknown = Object.getOwnPropertyDescriptor(Object.prototype, 'polluted') - expect(polluted).toBeUndefined() - expect(out).toEqual({ field_id: 'f1' }) - }) -}) + expect(toWireData({ createdAt: when })).toEqual({ created_at: when }); + }); + + it("does not allow prototype pollution through a __proto__ key", () => { + const malicious: unknown = JSON.parse( + '{"__proto__": {"polluted": true}, "fieldId": "f1"}' + ); + const out = toWireData(malicious); + const polluted: unknown = Object.getOwnPropertyDescriptor( + Object.prototype, + "polluted" + ); + expect(polluted).toBeUndefined(); + expect(out).toEqual({ field_id: "f1" }); + }); +}); diff --git a/embed/test/helpers.ts b/embed/test/helpers.ts index 823ea02c..ec63d2c4 100644 --- a/embed/test/helpers.ts +++ b/embed/test/helpers.ts @@ -1,12 +1,13 @@ -import { vi } from 'vitest' -import type { BridgeResult, Embed, IframeActions } from '../src/types' +import { vi } from "vitest"; +import type { BridgeResult, Embed, IframeActions } from "../src/types"; -const okResult: BridgeResult = { success: true, data: null } +const okResult: BridgeResult = { success: true, data: null }; // A fully-stubbed actions group: every editor operation is a vi.fn resolving to a // success Result. Shared by the tools + adapter tests. export const makeActionsStub = (): IframeActions => { - const method = (): Promise> => Promise.resolve(okResult) + const method = (): Promise> => + Promise.resolve(okResult); return { createField: vi.fn(method), deleteFields: vi.fn(method), @@ -24,8 +25,8 @@ export const makeActionsStub = (): IframeActions => { selectTool: vi.fn(method), setFieldValue: vi.fn(method), submit: vi.fn(method), - } -} + }; +}; // A minimal Embed handle wrapping stubbed actions (events + lifecycle are no-ops); // enough for adapters that only dispatch through embed.actions. @@ -33,4 +34,4 @@ export const makeEmbedStub = (): Embed => ({ actions: makeActionsStub(), events: { on: () => () => {} }, lifecycle: { dispose: () => {} }, -}) +}); diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index 8770d2ea..a1a2061e 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -1,419 +1,546 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { buildEditorDomain, buildEditorURL, createEmbed, EmbedConfigError, encodeContext } from '../src/mount' -import type { Embed } from '../src/types' +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildEditorDomain, + buildEditorURL, + createEmbed, + EmbedConfigError, + encodeContext, +} from "../src/mount"; +import type { Embed } from "../src/types"; // Recover the document URL from ?open= the same way the editor does. Kept here so this test pins the // CONTRACT both sides rely on - the SDK builds the ?open value and the editor reads it back - not just // the SDK half. const recoverDocumentUrlFromOpen = (requestUrl: string): string | null => { - const queryStart = requestUrl.indexOf('?') - if (queryStart === -1) return null - const match = requestUrl.slice(queryStart + 1).match(/(?:^|&)open=(.*)$/) - if (match === null) return null - const rawOpenValue = match[1] - const firstSegment = rawOpenValue.split('&')[0] + const queryStart = requestUrl.indexOf("?"); + if (queryStart === -1) return null; + const match = requestUrl.slice(queryStart + 1).match(/(?:^|&)open=(.*)$/); + if (match === null) return null; + const rawOpenValue = match[1]; + const firstSegment = rawOpenValue.split("&")[0]; const decode = (value: string): string => { try { - return decodeURIComponent(value) + return decodeURIComponent(value); } catch { - return value + return value; } - } + }; // No raw ? in the value: it is a single query param, so a + is an encoded space. A raw ? means the // URL carries its own query, which is kept verbatim. - const documentUrl = firstSegment.includes('?') ? decode(rawOpenValue) : decode(firstSegment.replace(/\+/g, ' ')) - return documentUrl === '' ? null : documentUrl -} + const documentUrl = firstSegment.includes("?") + ? decode(rawOpenValue) + : decode(firstSegment.replace(/\+/g, " ")); + return documentUrl === "" ? null : documentUrl; +}; describe(buildEditorURL.name, () => { const openFallbackRoundTrip = (documentUrl: string): string | null => { const editorUrl = buildEditorURL({ - editorOrigin: 'https://acme.simplepdf.com', + editorOrigin: "https://acme.simplepdf.com", locale: undefined, encodedContext: null, hasDocumentUrl: false, openFallbackUrl: documentUrl, - }) - const { pathname, search } = new URL(editorUrl) - return recoverDocumentUrlFromOpen(`${pathname}${search}`) - } + }); + const { pathname, search } = new URL(editorUrl); + return recoverDocumentUrlFromOpen(`${pathname}${search}`); + }; it.each([ - 'https://cdn.example.com/report.pdf', + "https://cdn.example.com/report.pdf", // A space in the consumer's URL is encoded as + by URLSearchParams; the editor must restore it. - 'https://cdn.example.com/my file.pdf', + "https://cdn.example.com/my file.pdf", // A literal + is encoded as %2B (not +), so it survives as a literal +. - 'https://cdn.example.com/a+b.pdf', + "https://cdn.example.com/a+b.pdf", // The document URL carries its own query - the whole thing is percent-encoded, so it round-trips. - 'https://gen.example.com/doc?id=7&v=2', + "https://gen.example.com/doc?id=7&v=2", // A base64 ?context= (contains a literal +) in the document's OWN query. - 'https://gen.example.com/doc?context=eyJjdXN0b21lcklkIjoiY3VzXzlmMyIsImNpdHkiOiLQnNC+0YHQutCy0LAifQ==', - ])('the ?open fallback URL round-trips through the editor parser: %s', (documentUrl) => { - expect(openFallbackRoundTrip(documentUrl)).toBe(documentUrl) - }) + "https://gen.example.com/doc?context=eyJjdXN0b21lcklkIjoiY3VzXzlmMyIsImNpdHkiOiLQnNC+0YHQutCy0LAifQ==", + ])( + "the ?open fallback URL round-trips through the editor parser: %s", + (documentUrl) => { + expect(openFallbackRoundTrip(documentUrl)).toBe(documentUrl); + } + ); - it('encodes context so the editor decodes it back to the original object', () => { - const context = { customerId: 'cus_9f3', ref: 'invoice-42' } + it("encodes context so the editor decodes it back to the original object", () => { + const context = { customerId: "cus_9f3", ref: "invoice-42" }; const editorUrl = buildEditorURL({ - editorOrigin: 'https://acme.simplepdf.com', + editorOrigin: "https://acme.simplepdf.com", locale: undefined, encodedContext: encodeContext(context), hasDocumentUrl: false, - openFallbackUrl: 'https://cdn.example.com/f.pdf', - }) + openFallbackUrl: "https://cdn.example.com/f.pdf", + }); // The editor URL-decodes the context param once, then base64-decodes it back to the object. - const contextParam = new URL(editorUrl).searchParams.get('context') - expect(contextParam).not.toBeNull() - expect(JSON.parse(atob(decodeURIComponent(contextParam ?? '')))).toEqual(context) - }) -}) + const contextParam = new URL(editorUrl).searchParams.get("context"); + expect(contextParam).not.toBeNull(); + expect(JSON.parse(atob(decodeURIComponent(contextParam ?? "")))).toEqual( + context + ); + }); +}); describe(buildEditorDomain.name, () => { - it('uses https for production base domains', () => { - expect(buildEditorDomain({ companyIdentifier: 'acme', baseDomain: 'simplepdf.com' })).toBe('https://acme.simplepdf.com') - }) - - it('uses http for local/.nil base domains', () => { - expect(buildEditorDomain({ companyIdentifier: 'acme', baseDomain: 'localhost:3000' })).toBe('http://acme.localhost:3000') - expect(buildEditorDomain({ companyIdentifier: 'acme', baseDomain: 'acme.nil' })).toBe('http://acme.acme.nil') - }) -}) + it("uses https for production base domains", () => { + expect( + buildEditorDomain({ + companyIdentifier: "acme", + baseDomain: "simplepdf.com", + }) + ).toBe("https://acme.simplepdf.com"); + }); + + it("uses http for local/.nil base domains", () => { + expect( + buildEditorDomain({ + companyIdentifier: "acme", + baseDomain: "localhost:3000", + }) + ).toBe("http://acme.localhost:3000"); + expect( + buildEditorDomain({ companyIdentifier: "acme", baseDomain: "acme.nil" }) + ).toBe("http://acme.acme.nil"); + }); +}); describe(encodeContext.name, () => { - it('returns null for undefined context', () => { - expect(encodeContext(undefined)).toBeNull() - }) - - it('base64-url-encodes a context object round-trip', () => { - const encoded = encodeContext({ ref: 'abc', n: 1 }) - expect(encoded).not.toBeNull() - const decoded = JSON.parse(atob(decodeURIComponent(encoded ?? ''))) - expect(decoded).toEqual({ ref: 'abc', n: 1 }) - }) - - it('returns null (does not throw) on a non-Latin1 context (btoa limitation)', () => { - expect(encodeContext({ name: 'café 😀 日本' })).toBeNull() - }) -}) + it("returns null for undefined context", () => { + expect(encodeContext(undefined)).toBeNull(); + }); + + it("base64-url-encodes a context object round-trip", () => { + const encoded = encodeContext({ ref: "abc", n: 1 }); + expect(encoded).not.toBeNull(); + const decoded = JSON.parse(atob(decodeURIComponent(encoded ?? ""))); + expect(decoded).toEqual({ ref: "abc", n: 1 }); + }); + + it("returns null (does not throw) on a non-Latin1 context (btoa limitation)", () => { + expect(encodeContext({ name: "café 😀 日本" })).toBeNull(); + }); +}); describe(createEmbed.name, () => { - const mounted: Embed[] = [] + const mounted: Embed[] = []; afterEach(() => { for (const embed of mounted) { - embed.lifecycle.dispose() + embed.lifecycle.dispose(); } - mounted.length = 0 - document.body.innerHTML = '' - }) + mounted.length = 0; + document.body.innerHTML = ""; + }); // Defaults companyIdentifier so the document/target tests stay terse; pass companyIdentifier to override. - const mount = (args: Omit[0], 'companyIdentifier'> & { companyIdentifier?: string }): Embed => { - const embed = createEmbed({ companyIdentifier: 'acme', ...args }) - mounted.push(embed) - return embed - } - - it('throws EmbedConfigError when the target selector matches nothing', () => { - expect(() => mount({ target: '#missing' })).toThrow(EmbedConfigError) - }) - - it('throws EmbedConfigError for an invalid companyIdentifier', () => { - document.body.innerHTML = '
' - expect(() => mount({ target: '#root', companyIdentifier: 'Not A Label' })).toThrow(/DNS label/) - }) + const mount = ( + args: Omit[0], "companyIdentifier"> & { + companyIdentifier?: string; + } + ): Embed => { + const embed = createEmbed({ companyIdentifier: "acme", ...args }); + mounted.push(embed); + return embed; + }; + + it("throws EmbedConfigError when the target selector matches nothing", () => { + expect(() => mount({ target: "#missing" })).toThrow(EmbedConfigError); + }); + + it("throws EmbedConfigError for an invalid companyIdentifier", () => { + document.body.innerHTML = '
'; + expect(() => + mount({ target: "#root", companyIdentifier: "Not A Label" }) + ).toThrow(/DNS label/); + }); // Mount with a document source against a fresh root (most document tests only // care that construction does/doesn't throw synchronously). - const mountWith = (doc: Parameters[0]['document']): Embed => { - document.body.innerHTML = '
' - return mount({ target: '#root', document: doc }) - } + const mountWith = ( + doc: Parameters[0]["document"] + ): Embed => { + document.body.innerHTML = '
'; + return mount({ target: "#root", document: doc }); + }; // --- Accepts (we don't gatekeep) ----------------------------------------- - it('accepts an http document url (mixed-content is the browser\'s gate, not ours)', () => { - expect(() => mountWith({ url: 'http://example.com/a.pdf' })).not.toThrow() - }) - - it('accepts a credentialed document url (routes through the editor ?open loader)', () => { - expect(() => mountWith({ url: 'https://user:pass@example.com/a.pdf' })).not.toThrow() - }) + it("accepts an http document url (mixed-content is the browser's gate, not ours)", () => { + expect(() => mountWith({ url: "http://example.com/a.pdf" })).not.toThrow(); + }); - it('accepts a Blob via { file }', () => { - const file = new File(['%PDF-1.4'], 'form.pdf', { type: 'application/pdf' }) - expect(() => mountWith({ file })).not.toThrow() - }) - - it('accepts a data URL via { dataUrl }', () => { - expect(() => mountWith({ dataUrl: 'data:application/pdf;base64,AAAA' })).not.toThrow() - }) + it("accepts a credentialed document url (routes through the editor ?open loader)", () => { + expect(() => + mountWith({ url: "https://user:pass@example.com/a.pdf" }) + ).not.toThrow(); + }); + + it("accepts a Blob via { file }", () => { + const file = new File(["%PDF-1.4"], "form.pdf", { + type: "application/pdf", + }); + expect(() => mountWith({ file })).not.toThrow(); + }); + + it("accepts a data URL via { dataUrl }", () => { + expect(() => + mountWith({ dataUrl: "data:application/pdf;base64,AAAA" }) + ).not.toThrow(); + }); // --- Rejects with a message that names the right arm ---------------------- - it('rejects a non-http(s) url scheme', () => { - expect(() => mountWith({ url: 'ftp://example.com/a.pdf' })).toThrow('http(s)') - }) + it("rejects a non-http(s) url scheme", () => { + expect(() => mountWith({ url: "ftp://example.com/a.pdf" })).toThrow( + "http(s)" + ); + }); - it('tells you to use { file } when a Blob is passed to url', () => { - const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' }) + it("tells you to use { file } when a Blob is passed to url", () => { + const blob = new Blob(["%PDF-1.4"], { type: "application/pdf" }); // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ url: blob })).toThrow('document: { file }') - }) + expect(() => mountWith({ url: blob })).toThrow("document: { file }"); + }); - it('tells you to use { dataUrl } when a data URL is passed to url', () => { - expect(() => mountWith({ url: 'data:application/pdf;base64,AAAA' })).toThrow('document: { dataUrl }') - }) + it("tells you to use { dataUrl } when a data URL is passed to url", () => { + expect(() => + mountWith({ url: "data:application/pdf;base64,AAAA" }) + ).toThrow("document: { dataUrl }"); + }); - it('tells you to use { file } when a blob: URL is passed to url', () => { - expect(() => mountWith({ url: 'blob:https://example.com/abc' })).toThrow('document: { file }') - }) + it("tells you to use { file } when a blob: URL is passed to url", () => { + expect(() => mountWith({ url: "blob:https://example.com/abc" })).toThrow( + "document: { file }" + ); + }); - it('tells you to use a url/dataUrl when a string is passed to file', () => { + it("tells you to use a url/dataUrl when a string is passed to file", () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ file: 'https://example.com/a.pdf' })).toThrow('document: { url }') - }) + expect(() => mountWith({ file: "https://example.com/a.pdf" })).toThrow( + "document: { url }" + ); + }); - it('tells you to use { file } when a Blob is passed to dataUrl', () => { - const blob = new Blob(['x'], { type: 'application/pdf' }) + it("tells you to use { file } when a Blob is passed to dataUrl", () => { + const blob = new Blob(["x"], { type: "application/pdf" }); // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ dataUrl: blob })).toThrow('document: { file }') - }) + expect(() => mountWith({ dataUrl: blob })).toThrow("document: { file }"); + }); - it('rejects a document with no source arm', () => { - expect(() => mountWith({})).toThrow('exactly one source') - }) + it("rejects a document with no source arm", () => { + expect(() => mountWith({})).toThrow("exactly one source"); + }); - it('rejects a document with more than one source arm', () => { + it("rejects a document with more than one source arm", () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ url: 'https://x.com/a.pdf', dataUrl: 'data:application/pdf;base64,AAAA' })).toThrow( - 'exactly one source', - ) - }) + expect(() => + mountWith({ + url: "https://x.com/a.pdf", + dataUrl: "data:application/pdf;base64,AAAA", + }) + ).toThrow("exactly one source"); + }); it('accepts the reserved "embed" companyIdentifier (reserved-portal rejection is WEM-only)', () => { - document.body.innerHTML = '
' - expect(() => mount({ target: '#root', companyIdentifier: 'embed' })).not.toThrow() - }) - - it('mounts an iframe under the target with the locale + context in the URL', () => { - document.body.innerHTML = '
' - mount({ target: '#root', companyIdentifier: 'acme', locale: 'fr', context: { a: 1 } }) - const iframe = document.querySelector('#root iframe') + document.body.innerHTML = '
'; + expect(() => + mount({ target: "#root", companyIdentifier: "embed" }) + ).not.toThrow(); + }); + + it("mounts an iframe under the target with the locale + context in the URL", () => { + document.body.innerHTML = '
'; + mount({ + target: "#root", + companyIdentifier: "acme", + locale: "fr", + context: { a: 1 }, + }); + const iframe = document.querySelector("#root iframe"); if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('expected an iframe under the target') + throw new Error("expected an iframe under the target"); } - const src = new URL(iframe.src) - expect(src.origin).toBe('https://acme.simplepdf.com') - expect(src.pathname).toBe('/fr/editor') - expect(src.searchParams.get('context')).not.toBeNull() - }) - - it('navigates straight to a SimplePDF documents URL (preserving its query, appending context, bridging to its own subdomain)', () => { - document.body.innerHTML = '
' + const src = new URL(iframe.src); + expect(src.origin).toBe("https://acme.simplepdf.com"); + expect(src.pathname).toBe("/fr/editor"); + expect(src.searchParams.get("context")).not.toBeNull(); + }); + + it("navigates straight to a SimplePDF documents URL (preserving its query, appending context, bridging to its own subdomain)", () => { + document.body.innerHTML = '
'; mount({ - target: '#root', - companyIdentifier: 'acme', + target: "#root", + companyIdentifier: "acme", context: { a: 1 }, - document: { url: 'https://demo.simplepdf.com/documents/abc-123?prefill=p-9' }, - }) - const iframe = document.querySelector('#root iframe') + document: { + url: "https://demo.simplepdf.com/documents/abc-123?prefill=p-9", + }, + }); + const iframe = document.querySelector("#root iframe"); if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('expected an iframe under the target') + throw new Error("expected an iframe under the target"); } - const src = new URL(iframe.src) + const src = new URL(iframe.src); // Bridges to the URL's own subdomain (demo), not the configured companyIdentifier (acme). - expect(src.origin).toBe('https://demo.simplepdf.com') - expect(src.pathname).toBe('/documents/abc-123') - expect(src.searchParams.get('prefill')).toBe('p-9') - expect(src.searchParams.get('context')).not.toBeNull() + expect(src.origin).toBe("https://demo.simplepdf.com"); + expect(src.pathname).toBe("/documents/abc-123"); + expect(src.searchParams.get("prefill")).toBe("p-9"); + expect(src.searchParams.get("context")).not.toBeNull(); // It is a direct navigation, not the host-fetch /editor path. - expect(src.searchParams.get('loadingPlaceholder')).toBeNull() - }) + expect(src.searchParams.get("loadingPlaceholder")).toBeNull(); + }); - it('treats a /documents/ url off the base-domain family as a normal PDF url (builds /editor)', () => { - document.body.innerHTML = '
' - mount({ target: '#root', companyIdentifier: 'acme', document: { url: 'https://evil.com/documents/abc-123' } }) - const iframe = document.querySelector('#root iframe') + it("treats a /documents/ url off the base-domain family as a normal PDF url (builds /editor)", () => { + document.body.innerHTML = '
'; + mount({ + target: "#root", + companyIdentifier: "acme", + document: { url: "https://evil.com/documents/abc-123" }, + }); + const iframe = document.querySelector("#root iframe"); if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('expected an iframe under the target') + throw new Error("expected an iframe under the target"); } - const src = new URL(iframe.src) - expect(src.origin).toBe('https://acme.simplepdf.com') - expect(src.pathname).toBe('/en/editor') - }) - - it('does not direct-load a documents URL on a nested subdomain (one tenant label only)', () => { - document.body.innerHTML = '
' - mount({ target: '#root', companyIdentifier: 'acme', document: { url: 'https://a.b.simplepdf.com/documents/abc' } }) - const iframe = document.querySelector('#root iframe') + const src = new URL(iframe.src); + expect(src.origin).toBe("https://acme.simplepdf.com"); + expect(src.pathname).toBe("/en/editor"); + }); + + it("does not direct-load a documents URL on a nested subdomain (one tenant label only)", () => { + document.body.innerHTML = '
'; + mount({ + target: "#root", + companyIdentifier: "acme", + document: { url: "https://a.b.simplepdf.com/documents/abc" }, + }); + const iframe = document.querySelector("#root iframe"); if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('expected an iframe under the target') + throw new Error("expected an iframe under the target"); } // a.b.simplepdf.com is two labels above the base domain → not a tenant documents // URL → falls back to the configured tenant's /editor host-fetch path. - expect(new URL(iframe.src).origin).toBe('https://acme.simplepdf.com') - expect(new URL(iframe.src).pathname).toBe('/en/editor') - }) - - it('does not direct-load a documents URL at the apex base domain (a tenant subdomain is required)', () => { - document.body.innerHTML = '
' - mount({ target: '#root', companyIdentifier: 'acme', document: { url: 'https://simplepdf.com/documents/abc' } }) - const iframe = document.querySelector('#root iframe') + expect(new URL(iframe.src).origin).toBe("https://acme.simplepdf.com"); + expect(new URL(iframe.src).pathname).toBe("/en/editor"); + }); + + it("does not direct-load a documents URL at the apex base domain (a tenant subdomain is required)", () => { + document.body.innerHTML = '
'; + mount({ + target: "#root", + companyIdentifier: "acme", + document: { url: "https://simplepdf.com/documents/abc" }, + }); + const iframe = document.querySelector("#root iframe"); if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('expected an iframe under the target') + throw new Error("expected an iframe under the target"); } - expect(new URL(iframe.src).pathname).toBe('/en/editor') - }) + expect(new URL(iframe.src).pathname).toBe("/en/editor"); + }); - it('does not direct-load a documents URL with extra path segments (anchored /documents/)', () => { - document.body.innerHTML = '
' + it("does not direct-load a documents URL with extra path segments (anchored /documents/)", () => { + document.body.innerHTML = '
'; mount({ - target: '#root', - companyIdentifier: 'acme', - document: { url: 'https://demo.simplepdf.com/documents/abc/extra' }, - }) - const iframe = document.querySelector('#root iframe') + target: "#root", + companyIdentifier: "acme", + document: { url: "https://demo.simplepdf.com/documents/abc/extra" }, + }); + const iframe = document.querySelector("#root iframe"); if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('expected an iframe under the target') + throw new Error("expected an iframe under the target"); } - expect(new URL(iframe.src).pathname).toBe('/en/editor') - }) - - it('removes the iframe it created on dispose', () => { - document.body.innerHTML = '
' - const embed = createEmbed({ target: '#root', companyIdentifier: 'acme' }) - expect(document.querySelector('#root iframe')).not.toBeNull() - embed.lifecycle.dispose() - expect(document.querySelector('#root iframe')).toBeNull() - }) - - it('loads the document once readiness is reached via the probe (gate posts LOAD_DOCUMENT)', async () => { - vi.useFakeTimers() - document.body.innerHTML = '
' - mount({ target: '#root', document: { dataUrl: 'data:application/pdf;base64,AAAA' } }) - const iframe = document.querySelector('#root iframe') - if (!(iframe instanceof HTMLIFrameElement) || iframe.contentWindow === null) { - throw new Error('expected an iframe with a contentWindow') + expect(new URL(iframe.src).pathname).toBe("/en/editor"); + }); + + it("removes the iframe it created on dispose", () => { + document.body.innerHTML = '
'; + const embed = createEmbed({ target: "#root", companyIdentifier: "acme" }); + expect(document.querySelector("#root iframe")).not.toBeNull(); + embed.lifecycle.dispose(); + expect(document.querySelector("#root iframe")).toBeNull(); + }); + + it("loads the document once readiness is reached via the probe (gate posts LOAD_DOCUMENT)", async () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + mount({ + target: "#root", + document: { dataUrl: "data:application/pdf;base64,AAAA" }, + }); + const iframe = document.querySelector("#root iframe"); + if ( + !(iframe instanceof HTMLIFrameElement) || + iframe.contentWindow === null + ) { + throw new Error("expected an iframe with a contentWindow"); } - const contentWindow = iframe.contentWindow - const posted: { type: string; request_id: string }[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const contentWindow = iframe.contentWindow; + const posted: { type: string; request_id: string }[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) + ); // Advance to a probe tick: readiness reached with NO EDITOR_READY / DOCUMENT_LOADED // event — only the probe. The readiness gate must still fire the deferred load. - await vi.advanceTimersByTimeAsync(500) - const probe = posted.find((message) => message.type === 'GET_FIELDS') + await vi.advanceTimersByTimeAsync(500); + const probe = posted.find((message) => message.type === "GET_FIELDS"); if (probe === undefined) { - throw new Error('expected a readiness probe') + throw new Error("expected a readiness probe"); } window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: probe.request_id, result: { success: true, data: { fields: [] } } }, + type: "REQUEST_RESULT", + data: { + request_id: probe.request_id, + result: { success: true, data: { fields: [] } }, + }, }), - origin: 'https://acme.simplepdf.com', + origin: "https://acme.simplepdf.com", source: contentWindow, - }), - ) + }) + ); // Flush the gate's microtask + the async data-URL resolution. - await vi.advanceTimersByTimeAsync(0) - expect(posted.some((message) => message.type === 'LOAD_DOCUMENT')).toBe(true) - vi.restoreAllMocks() - vi.useRealTimers() - }) - - it('attaches to an existing ' - const iframeCountBefore = document.querySelectorAll('iframe').length - const embed = mount({ target: '#ed', companyIdentifier: 'acme' }) + await vi.advanceTimersByTimeAsync(0); + expect(posted.some((message) => message.type === "LOAD_DOCUMENT")).toBe( + true + ); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("attaches to an existing '; + const iframeCountBefore = document.querySelectorAll("iframe").length; + const embed = mount({ target: "#ed", companyIdentifier: "acme" }); // No new iframe is created: we bridge to the one you rendered. - expect(document.querySelectorAll('iframe').length).toBe(iframeCountBefore) + expect(document.querySelectorAll("iframe").length).toBe(iframeCountBefore); // dispose() must NOT remove an iframe the consumer owns. - embed.lifecycle.dispose() - expect(document.querySelector('#ed')).not.toBeNull() - }) - - it('rejects an attached iframe whose src origin does not match the companyIdentifier', () => { - document.body.innerHTML = '' - expect(() => mount({ target: '#ed', companyIdentifier: 'acme' })).toThrow('https://other.simplepdf.com') - }) - - it('rejects a documents URL when attaching to an existing iframe (mount-only feature)', () => { - document.body.innerHTML = '' + embed.lifecycle.dispose(); + expect(document.querySelector("#ed")).not.toBeNull(); + }); + + it("rejects an attached iframe whose src origin does not match the companyIdentifier", () => { + document.body.innerHTML = + ''; + expect(() => mount({ target: "#ed", companyIdentifier: "acme" })).toThrow( + "https://other.simplepdf.com" + ); + }); + + it("rejects a documents URL when attaching to an existing iframe (mount-only feature)", () => { + document.body.innerHTML = + ''; expect(() => - mount({ target: '#ed', companyIdentifier: 'acme', document: { url: 'https://demo.simplepdf.com/documents/abc' } }), - ).toThrow(/documents URL/) - }) - - it('does not false-positive the origin guard when the iframe has no usable src', () => { - for (const html of ['', '', '']) { - document.body.innerHTML = html - expect(() => mount({ target: '#x', companyIdentifier: 'acme' })).not.toThrow() + mount({ + target: "#ed", + companyIdentifier: "acme", + document: { url: "https://demo.simplepdf.com/documents/abc" }, + }) + ).toThrow(/documents URL/); + }); + + it("does not false-positive the origin guard when the iframe has no usable src", () => { + for (const html of [ + '', + '', + '', + ]) { + document.body.innerHTML = html; + expect(() => + mount({ target: "#x", companyIdentifier: "acme" }) + ).not.toThrow(); } - }) + }); - it('rejects a missing companyIdentifier', () => { + it("rejects a missing companyIdentifier", () => { // @ts-expect-error companyIdentifier is required; exercise the runtime guard for untyped JS callers - expect(() => createEmbed({ target: '#root' })).toThrow(/companyIdentifier is required/) - }) + expect(() => createEmbed({ target: "#root" })).toThrow( + /companyIdentifier is required/ + ); + }); - it('rejects a non-element, non-string target with a clean error', () => { + it("rejects a non-element, non-string target with a clean error", () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed({ target: 123, companyIdentifier: 'acme' })).toThrow(/target must be/) - }) + expect(() => + createEmbed({ target: 123, companyIdentifier: "acme" }) + ).toThrow(/target must be/); + }); - it('rejects a non-object document with a clean error', () => { - document.body.innerHTML = '
' + it("rejects a non-object document with a clean error", () => { + document.body.innerHTML = '
'; // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mount({ target: '#root', document: null })).toThrow(/document must be an object/) - }) + expect(() => mount({ target: "#root", document: null })).toThrow( + /document must be an object/ + ); + }); - it('rejects a non-object args with a clean error', () => { + it("rejects a non-object args with a clean error", () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(null)).toThrow(/config object/) - }) + expect(() => createEmbed(null)).toThrow(/config object/); + }); - it('rejects a non-string baseDomain with a clean error', () => { + it("rejects a non-string baseDomain with a clean error", () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed({ target: '#root', companyIdentifier: 'acme', baseDomain: 123 })).toThrow(/baseDomain must be a string/) - }) + expect(() => + createEmbed({ + target: "#root", + companyIdentifier: "acme", + baseDomain: 123, + }) + ).toThrow(/baseDomain must be a string/); + }); // Every malformed shape an untyped JS caller can produce fails loud: `exclude` is // the control that withholds irreversible operations, so it must never fail open. it.each([ - ['a bare boolean', true], - ['an object without enabled', { exclude: ['submit'] }], - ['a stringly-typed enabled', { enabled: 'true' }], - ['a stringly-typed flag', 'false'], - ['a number', 0], - ['null', null], - ['a string exclude', { enabled: true, exclude: 'submit' }], - ['a non-string exclude entry', { enabled: true, exclude: ['submit', 7] }], - ])('throws EmbedConfigError when webMCP is %s', (_label, webMCP) => { - document.body.innerHTML = '
' - const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', webMCP } + ["a bare boolean", true], + ["an object without enabled", { exclude: ["submit"] }], + ["a stringly-typed enabled", { enabled: "true" }], + ["a stringly-typed flag", "false"], + ["a number", 0], + ["null", null], + ["a string exclude", { enabled: true, exclude: "submit" }], + ["a non-string exclude entry", { enabled: true, exclude: ["submit", 7] }], + ])("throws EmbedConfigError when webMCP is %s", (_label, webMCP) => { + document.body.innerHTML = '
'; + const malformedArgs: unknown = { + target: "#root", + companyIdentifier: "acme", + webMCP, + }; // @ts-expect-error exercising the runtime guard for untyped JS callers expect(() => createEmbed(malformedArgs)).toThrow( - /webMCP must be \{ enabled: false \} or \{ enabled: true, exclude\?: MethodName\[\] \}/, - ) - }) + /webMCP must be \{ enabled: false \} or \{ enabled: true, exclude\?: MethodName\[\] \}/ + ); + }); it('throws EmbedConfigError when the exclude key itself is misspelled, so the typo cannot read as "nothing withheld"', () => { - document.body.innerHTML = '
' - const misspelledKey: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, exlude: ['submit'] } } + document.body.innerHTML = '
'; + const misspelledKey: unknown = { + target: "#root", + companyIdentifier: "acme", + webMCP: { enabled: true, exlude: ["submit"] }, + }; // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(misspelledKey)).toThrow(/webMCP has no option exlude \(known: enabled, exclude\)/) - }) - - it('throws EmbedConfigError when exclude names no tool, so a misspelled name cannot register the operation it meant to withhold', () => { - document.body.innerHTML = '
' - const misspelled: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, exclude: ['sumbit'] } } + expect(() => createEmbed(misspelledKey)).toThrow( + /webMCP has no option exlude \(known: enabled, exclude\)/ + ); + }); + + it("throws EmbedConfigError when exclude names no tool, so a misspelled name cannot register the operation it meant to withhold", () => { + document.body.innerHTML = '
'; + const misspelled: unknown = { + target: "#root", + companyIdentifier: "acme", + webMCP: { enabled: true, exclude: ["sumbit"] }, + }; // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(misspelled)).toThrow(/webMCP\.exclude names no tool: sumbit \(known: createField/) - }) -}) + expect(() => createEmbed(misspelled)).toThrow( + /webMCP\.exclude names no tool: sumbit \(known: batchSetFieldValue, createField/ + ); + }); +}); diff --git a/embed/test/tanstack-ai.test.ts b/embed/test/tanstack-ai.test.ts index 1b93ada9..deab2e81 100644 --- a/embed/test/tanstack-ai.test.ts +++ b/embed/test/tanstack-ai.test.ts @@ -1,48 +1,61 @@ -import { describe, expect, it } from 'vitest' -import { createSimplePDFTools, simplePDFToolDefinitions } from '../src/tanstack-ai' -import type { BridgeResult } from '../src/types' -import { makeEmbedStub } from './helpers' +import { describe, expect, it } from "vitest"; +import { + createSimplePDFTools, + simplePDFToolDefinitions, +} from "../src/tanstack-ai"; +import type { BridgeResult } from "../src/types"; +import { makeEmbedStub } from "./helpers"; -describe('simplePDFToolDefinitions', () => { - it('returns the 15 agentic operations as execute-less definitions (loadDocument excluded)', () => { - const definitions = simplePDFToolDefinitions() - expect(definitions).toHaveLength(15) - expect(definitions.map((definition) => definition.name)).not.toContain('loadDocument') +describe("simplePDFToolDefinitions", () => { + it("returns the 16 agentic operations as execute-less definitions (loadDocument excluded)", () => { + const definitions = simplePDFToolDefinitions(); + expect(definitions).toHaveLength(16); + expect(definitions.map((definition) => definition.name)).not.toContain( + "loadDocument" + ); for (const definition of definitions) { - expect(typeof definition.description).toBe('string') - expect(definition.inputSchema).toBeDefined() + expect(typeof definition.description).toBe("string"); + expect(definition.inputSchema).toBeDefined(); } - }) -}) + }); +}); -describe('createSimplePDFTools', () => { - it('produces a client tool for each of the 15 agentic operations', () => { - const tools = createSimplePDFTools({ embed: makeEmbedStub() }) - expect(tools).toHaveLength(15) - expect(tools.every((tool) => typeof tool.execute === 'function')).toBe(true) - }) +describe("createSimplePDFTools", () => { + it("produces a client tool for each of the 16 agentic operations", () => { + const tools = createSimplePDFTools({ embed: makeEmbedStub() }); + expect(tools).toHaveLength(16); + expect(tools.every((tool) => typeof tool.execute === "function")).toBe( + true + ); + }); - it('binds each tool to the editor: a client call validates input + dispatches to the matching action', async () => { - const embed = makeEmbedStub() - const goTo = createSimplePDFTools({ embed }).find((tool) => tool.name === 'goTo') + it("binds each tool to the editor: a client call validates input + dispatches to the matching action", async () => { + const embed = makeEmbedStub(); + const goTo = createSimplePDFTools({ embed }).find( + (tool) => tool.name === "goTo" + ); if (goTo === undefined || goTo.execute === undefined) { - throw new Error('expected a goTo client tool with an execute') + throw new Error("expected a goTo client tool with an execute"); } - await goTo.execute({ page: 2 }) - expect(embed.actions.goTo).toHaveBeenCalledWith({ page: 2 }) - }) + await goTo.execute({ page: 2 }); + expect(embed.actions.goTo).toHaveBeenCalledWith({ page: 2 }); + }); - it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => { - const embed = makeEmbedStub() - const goTo = createSimplePDFTools({ embed }).find((tool) => tool.name === 'goTo') + it("returns bad_request:invalid_input on schema-invalid input without dispatching", async () => { + const embed = makeEmbedStub(); + const goTo = createSimplePDFTools({ embed }).find( + (tool) => tool.name === "goTo" + ); if (goTo === undefined || goTo.execute === undefined) { - throw new Error('expected a goTo client tool with an execute') + throw new Error("expected a goTo client tool with an execute"); } - const result: BridgeResult = await goTo.execute({ page: 'not-a-number' }) - expect(result.success).toBe(false) + const result: BridgeResult = await goTo.execute({ + page: "not-a-number", + }); + expect(result.success).toBe(false); if (!result.success) { - expect(result.error.code).toBe('bad_request:invalid_input') + expect(result.error.code).toBe("bad_request:invalid_input"); } - expect(embed.actions.goTo).not.toHaveBeenCalled() - }) -}) + expect(embed.actions.goTo).not.toHaveBeenCalled(); + }); +}); diff --git a/embed/test/tools.test.ts b/embed/test/tools.test.ts index dfb4639e..390b4fb7 100644 --- a/embed/test/tools.test.ts +++ b/embed/test/tools.test.ts @@ -1,59 +1,65 @@ -import { describe, expect, it } from 'vitest' -import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS } from '../src/tools' -import { makeActionsStub } from './helpers' +import { describe, expect, it } from "vitest"; +import { + isSimplePDFToolName, + routeToolCall, + SIMPLEPDF_TOOLS, +} from "../src/tools"; +import { makeActionsStub } from "./helpers"; describe(isSimplePDFToolName.name, () => { - it('accepts agentic tool names', () => { - expect(isSimplePDFToolName('getFields')).toBe(true) - expect(isSimplePDFToolName('goTo')).toBe(true) - expect(isSimplePDFToolName('createField')).toBe(true) - }) + it("accepts agentic tool names", () => { + expect(isSimplePDFToolName("getFields")).toBe(true); + expect(isSimplePDFToolName("goTo")).toBe(true); + expect(isSimplePDFToolName("createField")).toBe(true); + }); - it('rejects loadDocument (host/setup, not an agentic tool) and unknown names', () => { - expect(isSimplePDFToolName('loadDocument')).toBe(false) - expect(isSimplePDFToolName('not_a_tool')).toBe(false) - expect(isSimplePDFToolName(42)).toBe(false) - }) -}) + it("rejects loadDocument (host/setup, not an agentic tool) and unknown names", () => { + expect(isSimplePDFToolName("loadDocument")).toBe(false); + expect(isSimplePDFToolName("not_a_tool")).toBe(false); + expect(isSimplePDFToolName(42)).toBe(false); + }); +}); -describe('SIMPLEPDF_TOOLS', () => { - it('exposes the 15 agentic operations with descriptions + input schemas (loadDocument excluded)', () => { - const names = Object.keys(SIMPLEPDF_TOOLS) - expect(names).toHaveLength(15) - expect(names).not.toContain('loadDocument') +describe("SIMPLEPDF_TOOLS", () => { + it("exposes the 16 agentic operations with descriptions + input schemas (loadDocument excluded)", () => { + const names = Object.keys(SIMPLEPDF_TOOLS); + expect(names).toHaveLength(16); + expect(names).not.toContain("loadDocument"); for (const definition of Object.values(SIMPLEPDF_TOOLS)) { - expect(typeof definition.description).toBe('string') - expect(definition.inputSchema).toBeDefined() + expect(typeof definition.description).toBe("string"); + expect(definition.inputSchema).toBeDefined(); } - }) -}) + }); +}); describe(routeToolCall.name, () => { - it('validates input against the tool schema, then dispatches to the matching action', async () => { - const actions = makeActionsStub() - await routeToolCall(actions, 'goTo', { page: 2 }) - expect(actions.goTo).toHaveBeenCalledWith({ page: 2 }) - }) + it("validates input against the tool schema, then dispatches to the matching action", async () => { + const actions = makeActionsStub(); + await routeToolCall(actions, "goTo", { page: 2 }); + expect(actions.goTo).toHaveBeenCalledWith({ page: 2 }); + }); - it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => { - const actions = makeActionsStub() - const result = await routeToolCall(actions, 'goTo', { page: 'not-a-number' }) - expect(result.success).toBe(false) + it("returns bad_request:invalid_input on schema-invalid input without dispatching", async () => { + const actions = makeActionsStub(); + const result = await routeToolCall(actions, "goTo", { + page: "not-a-number", + }); + expect(result.success).toBe(false); if (!result.success) { - expect(result.error.code).toBe('bad_request:invalid_input') + expect(result.error.code).toBe("bad_request:invalid_input"); } - expect(actions.goTo).not.toHaveBeenCalled() - }) + expect(actions.goTo).not.toHaveBeenCalled(); + }); - it('routes getAnnotatedPage to its action with the validated page', async () => { - const actions = makeActionsStub() - await routeToolCall(actions, 'getAnnotatedPage', { page: 1 }) - expect(actions.getAnnotatedPage).toHaveBeenCalledWith({ page: 1 }) - }) + it("routes getAnnotatedPage to its action with the validated page", async () => { + const actions = makeActionsStub(); + await routeToolCall(actions, "getAnnotatedPage", { page: 1 }); + expect(actions.getAnnotatedPage).toHaveBeenCalledWith({ page: 1 }); + }); - it('dispatches no-input tools without requiring input', async () => { - const actions = makeActionsStub() - await routeToolCall(actions, 'getFields', undefined) - expect(actions.getFields).toHaveBeenCalledTimes(1) - }) -}) + it("dispatches no-input tools without requiring input", async () => { + const actions = makeActionsStub(); + await routeToolCall(actions, "getFields", undefined); + expect(actions.getFields).toHaveBeenCalledTimes(1); + }); +}); diff --git a/embed/test/unwrap.test.ts b/embed/test/unwrap.test.ts index b6935d5c..b9506b24 100644 --- a/embed/test/unwrap.test.ts +++ b/embed/test/unwrap.test.ts @@ -1,27 +1,33 @@ -import { describe, expect, it } from 'vitest' -import { BridgeUnwrapError, unwrap } from '../src/unwrap' -import type { BridgeResult } from '../src/types' +import { describe, expect, it } from "vitest"; +import { BridgeUnwrapError, unwrap } from "../src/unwrap"; +import type { BridgeResult } from "../src/types"; describe(unwrap.name, () => { - it('returns data for a successful result', () => { - const result: BridgeResult<{ detected_count: number }> = { success: true, data: { detected_count: 3 } } - expect(unwrap(result)).toEqual({ detected_count: 3 }) - }) + it("returns data for a successful result", () => { + const result: BridgeResult<{ detected_count: number }> = { + success: true, + data: { detected_count: 3 }, + }; + expect(unwrap(result)).toEqual({ detected_count: 3 }); + }); - it('throws BridgeUnwrapError carrying the typed error for a failed result', () => { + it("throws BridgeUnwrapError carrying the typed error for a failed result", () => { const result: BridgeResult = { success: false, - error: { code: 'forbidden:editing_not_allowed', message: 'Editing is disabled' }, - } + error: { + code: "forbidden:editing_not_allowed", + message: "Editing is disabled", + }, + }; try { - unwrap(result) - throw new Error('expected unwrap to throw') + unwrap(result); + throw new Error("expected unwrap to throw"); } catch (caught) { - expect(caught).toBeInstanceOf(BridgeUnwrapError) + expect(caught).toBeInstanceOf(BridgeUnwrapError); if (caught instanceof BridgeUnwrapError) { - expect(caught.error.code).toBe('forbidden:editing_not_allowed') - expect(caught.message).toBe('Editing is disabled') + expect(caught.error.code).toBe("forbidden:editing_not_allowed"); + expect(caught.message).toBe("Editing is disabled"); } } - }) -}) + }); +}); diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index a6b10f24..c17ee298 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -1,448 +1,632 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { attachEmbed, type AttachEmbedArgs } from '../src/bridge' -import type { BridgeLogger } from '../src/logger' -import type { Embed } from '../src/types' -import { METHOD_NAMES } from '../src/generated/method-names' -import { WEBMCP_TOOLS } from '../src/generated/webmcp-tools' +import { afterEach, describe, expect, it, vi } from "vitest"; +import { attachEmbed, type AttachEmbedArgs } from "../src/bridge"; +import type { BridgeLogger } from "../src/logger"; +import type { Embed } from "../src/types"; +import { METHOD_NAMES } from "../src/generated/method-names"; +import { WEBMCP_TOOLS } from "../src/generated/webmcp-tools"; -const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' +const EDITOR_ORIGIN = "https://tenant.simplepdf.com"; // The slice of a WebMCP tool descriptor these tests read back. type RegisteredTool = { - name: string - description: string - inputSchema: { type: string; properties?: Record; required?: readonly string[] } - annotations: { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } + name: string; + description: string; + inputSchema: { + type: string; + properties?: Record; + required?: readonly string[]; + }; + annotations: { + readOnlyHint?: boolean; + untrustedContentHint?: boolean; + destructiveHint?: boolean; + openWorldHint?: boolean; + }; execute: ( input: unknown, - options?: { signal: AbortSignal }, + options?: { signal: AbortSignal } ) => Promise<{ - content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> - isError?: boolean - }> -} + content: Array< + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + >; + isError?: boolean; + }>; +}; type FakeModelContext = { - registerTool: (tool: RegisteredTool, options: { signal: AbortSignal }) => void - registered: RegisteredTool[] - liveToolNames: () => string[] -} - -const originalDocumentModelContext = Object.getOwnPropertyDescriptor(document, 'modelContext') -const originalNavigatorModelContext = Object.getOwnPropertyDescriptor(navigator, 'modelContext') - -const restoreModelContext = (target: object, descriptor: PropertyDescriptor | undefined): void => { + registerTool: ( + tool: RegisteredTool, + options: { signal: AbortSignal } + ) => void; + registered: RegisteredTool[]; + liveToolNames: () => string[]; +}; + +const originalDocumentModelContext = Object.getOwnPropertyDescriptor( + document, + "modelContext" +); +const originalNavigatorModelContext = Object.getOwnPropertyDescriptor( + navigator, + "modelContext" +); + +const restoreModelContext = ( + target: object, + descriptor: PropertyDescriptor | undefined +): void => { if (descriptor === undefined) { - Reflect.deleteProperty(target, 'modelContext') - return + Reflect.deleteProperty(target, "modelContext"); + return; } - Object.defineProperty(target, 'modelContext', descriptor) -} + Object.defineProperty(target, "modelContext", descriptor); +}; // A minimal native-like model context: it records registrations and drops a tool from // the live set when its registration signal aborts (the spec's unregister mechanism). const installModelContext = ( host: Document | Navigator, - { rejectTool }: { rejectTool?: string } = {}, + { rejectTool }: { rejectTool?: string } = {} ): FakeModelContext => { - const registered: RegisteredTool[] = [] - const liveTools = new Set() + const registered: RegisteredTool[] = []; + const liveTools = new Set(); const modelContext: FakeModelContext = { registerTool: (tool, { signal }) => { if (tool.name === rejectTool) { - throw new Error(`runtime rejected ${tool.name}`) + throw new Error(`runtime rejected ${tool.name}`); } - registered.push(tool) - liveTools.add(tool.name) - signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true }) + registered.push(tool); + liveTools.add(tool.name); + signal.addEventListener("abort", () => liveTools.delete(tool.name), { + once: true, + }); }, registered, liveToolNames: () => [...liveTools], - } - Object.defineProperty(host, 'modelContext', { configurable: true, value: modelContext }) - return modelContext -} - -const makeLogger = (): BridgeLogger => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) - -type Posted = { type: string; request_id: string; data: unknown } + }; + Object.defineProperty(host, "modelContext", { + configurable: true, + value: modelContext, + }); + return modelContext; +}; + +const makeLogger = (): BridgeLogger => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}); + +type Posted = { type: string; request_id: string; data: unknown }; type Harness = { - embed: Embed - posted: Posted[] - reply: (request: Posted, result: unknown) => void + embed: Embed; + posted: Posted[]; + reply: (request: Posted, result: unknown) => void; // Registration waits for the editor to be alive; these are the editor's lifecycle announcements. - markEditorReady: () => void - markDocumentLoaded: () => void -} - -const harnesses: Harness[] = [] - -const makeHarness = (args: Pick): Harness => { - const iframe = document.createElement('iframe') - document.body.appendChild(iframe) - const contentWindow = iframe.contentWindow + markEditorReady: () => void; + markDocumentLoaded: () => void; +}; + +const harnesses: Harness[] = []; + +const makeHarness = ( + args: Pick +): Harness => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const contentWindow = iframe.contentWindow; if (contentWindow === null) { - throw new Error('jsdom iframe has no contentWindow') + throw new Error("jsdom iframe has no contentWindow"); } - const posted: Posted[] = [] - vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { - if (typeof message === 'string') { - posted.push(JSON.parse(message)) + const posted: Posted[] = []; + vi.spyOn(contentWindow, "postMessage").mockImplementation( + (message: unknown) => { + if (typeof message === "string") { + posted.push(JSON.parse(message)); + } } - }) - const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, ...args }) + ); + const embed = attachEmbed({ + getIframe: () => iframe, + editorOrigin: EDITOR_ORIGIN, + ...args, + }); const receive = (message: unknown): void => { window.dispatchEvent( - new MessageEvent('message', { data: JSON.stringify(message), origin: EDITOR_ORIGIN, source: contentWindow }), - ) - } + new MessageEvent("message", { + data: JSON.stringify(message), + origin: EDITOR_ORIGIN, + source: contentWindow, + }) + ); + }; const harness: Harness = { embed, posted, - reply: (request, result) => receive({ type: 'REQUEST_RESULT', data: { request_id: request.request_id, result } }), - markEditorReady: () => receive({ type: 'EDITOR_READY', data: {} }), - markDocumentLoaded: () => receive({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc1' } }), - } - harnesses.push(harness) - return harness -} + reply: (request, result) => + receive({ + type: "REQUEST_RESULT", + data: { request_id: request.request_id, result }, + }), + markEditorReady: () => receive({ type: "EDITOR_READY", data: {} }), + markDocumentLoaded: () => + receive({ type: "DOCUMENT_LOADED", data: { document_id: "doc1" } }), + }; + harnesses.push(harness); + return harness; +}; // A ready embed with the option on: registration is asynchronous (the WebMCP module // is lazy-loaded), so callers wait for the expected tool count rather than reading it // synchronously. -const mountReady = (args: Pick): Harness => { - const harness = makeHarness(args) - harness.markEditorReady() - return harness -} - -const waitForTools = (modelContext: FakeModelContext, count: number): Promise => - vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)) - -const TOOL_COUNT = METHOD_NAMES.length -const toolName = (method: keyof typeof WEBMCP_TOOLS): string => WEBMCP_TOOLS[method].name +const mountReady = ( + args: Pick +): Harness => { + const harness = makeHarness(args); + harness.markEditorReady(); + return harness; +}; + +const waitForTools = ( + modelContext: FakeModelContext, + count: number +): Promise => + vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)); + +const TOOL_COUNT = METHOD_NAMES.length; +const toolName = (method: keyof typeof WEBMCP_TOOLS): string => + WEBMCP_TOOLS[method].name; // The bridge's readiness probe posts its own GET_FIELDS requests while the editor is // booting, so a tool call's request is located by type rather than by position. -const waitForRequest = async (harness: Harness, type: string): Promise => { - await vi.waitFor(() => expect(harness.posted.some((message) => message.type === type)).toBe(true)) - const request = harness.posted.find((message) => message.type === type) +const waitForRequest = async ( + harness: Harness, + type: string +): Promise => { + await vi.waitFor(() => + expect(harness.posted.some((message) => message.type === type)).toBe(true) + ); + const request = harness.posted.find((message) => message.type === type); if (request === undefined) { - throw new Error(`no ${type} request posted`) + throw new Error(`no ${type} request posted`); } - return request -} + return request; +}; -const findTool = (modelContext: FakeModelContext, name: string): RegisteredTool => { - const tool = modelContext.registered.find((candidate) => candidate.name === name) +const findTool = ( + modelContext: FakeModelContext, + name: string +): RegisteredTool => { + const tool = modelContext.registered.find( + (candidate) => candidate.name === name + ); if (tool === undefined) { - throw new Error(`tool ${name} was not registered`) + throw new Error(`tool ${name} was not registered`); } - return tool -} + return tool; +}; -describe('attachEmbed({ webMCP })', () => { +describe("attachEmbed({ webMCP })", () => { afterEach(() => { for (const harness of harnesses) { - harness.embed.lifecycle.dispose() + harness.embed.lifecycle.dispose(); } - harnesses.length = 0 - document.body.innerHTML = '' - restoreModelContext(document, originalDocumentModelContext) - restoreModelContext(navigator, originalNavigatorModelContext) - vi.restoreAllMocks() - }) - - it('registers every operation on document.modelContext as the manifest tool record: prefixed name, description, snake_case input schema and behavior hints', async () => { - const modelContext = installModelContext(document) - mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) + harnesses.length = 0; + document.body.innerHTML = ""; + restoreModelContext(document, originalDocumentModelContext); + restoreModelContext(navigator, originalNavigatorModelContext); + vi.restoreAllMocks(); + }); + + it("registers every operation on document.modelContext as the manifest tool record: prefixed name, description, snake_case input schema and behavior hints", async () => { + const modelContext = installModelContext(document); + mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual( Object.values(WEBMCP_TOOLS) .map((tool) => tool.name) - .sort(), - ) - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + .sort() + ); + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); // loadDocument is a host-page tool like it is in the editor's own registration. - expect(modelContext.liveToolNames()).toContain('simplepdf_embed_load_document') - const setFieldValue = findTool(modelContext, 'simplepdf_embed_set_field_value') - expect(setFieldValue.description).toMatch(/^Set the value of an existing field/) - expect(setFieldValue.inputSchema.type).toBe('object') - expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual(['animate', 'field_id', 'value']) - expect(setFieldValue.inputSchema.required).toEqual(['field_id', 'value']) + expect(modelContext.liveToolNames()).toContain( + "simplepdf_embed_load_document" + ); + const setFieldValue = findTool( + modelContext, + "simplepdf_embed_set_field_value" + ); + expect(setFieldValue.description).toMatch( + /^Set the value of an existing field/ + ); + expect(setFieldValue.inputSchema.type).toBe("object"); + expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual([ + "animate", + "field_id", + "value", + ]); + expect(setFieldValue.inputSchema.required).toEqual(["field_id", "value"]); for (const tool of modelContext.registered) { - const hasExplicitHint = tool.annotations.readOnlyHint === true || typeof tool.annotations.destructiveHint === 'boolean' - expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe(true) + const hasExplicitHint = + tool.annotations.readOnlyHint === true || + typeof tool.annotations.destructiveHint === "boolean"; + expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe( + true + ); } // The readers hand document-derived content to the agent: read-only AND untrusted. - expect(findTool(modelContext, 'simplepdf_embed_get_fields').annotations).toEqual({ + expect( + findTool(modelContext, "simplepdf_embed_get_fields").annotations + ).toEqual({ readOnlyHint: true, untrustedContentHint: true, - }) - expect(findTool(modelContext, 'simplepdf_embed_get_annotated_page').annotations).toEqual({ + }); + expect( + findTool(modelContext, "simplepdf_embed_get_annotated_page").annotations + ).toEqual({ readOnlyHint: true, untrustedContentHint: true, - }) - expect(findTool(modelContext, 'simplepdf_embed_submit').annotations).toEqual({ destructiveHint: true }) + }); + expect( + findTool(modelContext, "simplepdf_embed_submit").annotations + ).toEqual({ destructiveHint: true }); // The hints are the manifest's, openWorldHint included (the editor fetches an agent-supplied URL). - expect(findTool(modelContext, 'simplepdf_embed_set_field_value').annotations).toEqual({ + expect( + findTool(modelContext, "simplepdf_embed_set_field_value").annotations + ).toEqual({ destructiveHint: false, openWorldHint: true, - }) - }) + }); + }); - it('waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe', async () => { - const modelContext = installModelContext(document) - const booting = makeHarness({ webMCP: { enabled: true } }) + it("waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe", async () => { + const modelContext = installModelContext(document); + const booting = makeHarness({ webMCP: { enabled: true } }); // Control: a ready embed on the same context proves the lazy path had time to run; // every live name is the control's, so the booting embed registered nothing. - const control = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) - control.embed.lifecycle.dispose() - expect(modelContext.liveToolNames()).toEqual([]) - - booting.markEditorReady() - await waitForTools(modelContext, TOOL_COUNT * 2) - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) - }) - - it('withholds the excluded operations and registers the rest', async () => { - const modelContext = installModelContext(document) - const logger = makeLogger() - mountReady({ webMCP: { enabled: true, exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger }) - await waitForTools(modelContext, TOOL_COUNT - 4) - - const names = modelContext.registered.map((tool) => tool.name) - expect(names).toContain(toolName('setFieldValue')) - expect(names).toContain(toolName('getFields')) - expect(names).not.toContain(toolName('submit')) - expect(names).not.toContain(toolName('deletePages')) - expect(logger.warn).not.toHaveBeenCalled() - }) - - it('executes a tool call as the operation request on the wire and returns the editor Result, wire-shaped, as a JSON-text tool result', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - const pendingResult = findTool(modelContext, 'simplepdf_embed_set_field_value').execute({ field_id: 'f1', value: 'Jane' }) - const request = await waitForRequest(harness, 'SET_FIELD_VALUE') - expect(request.data).toEqual({ field_id: 'f1', value: 'Jane' }) - harness.reply(request, { success: true }) - const toolResult = await pendingResult - expect(toolResult.isError).toBeUndefined() - expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify({ success: true, data: null }) }]) - }) - - it('hands the agent the wire-shaped result its tool description promises (snake_case, not the SDK camelCase)', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - const pendingResult = findTool(modelContext, 'simplepdf_embed_create_field').execute({ type: 'TEXT', x: 1, y: 2, width: 3, height: 4, page: 1 }) - const request = await waitForRequest(harness, 'CREATE_FIELD') - harness.reply(request, { success: true, data: { field_id: 'f_new' } }) - const toolResult = await pendingResult - expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify({ success: true, data: { field_id: 'f_new' } }) }]) - }) - - it('returns the annotated page render once, as an image block, with the badges map in the text block', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - const pendingResult = findTool(modelContext, 'simplepdf_embed_get_annotated_page').execute({ page: 1 }) - const request = await waitForRequest(harness, 'GET_ANNOTATED_PAGE') + const control = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); + control.embed.lifecycle.dispose(); + expect(modelContext.liveToolNames()).toEqual([]); + + booting.markEditorReady(); + await waitForTools(modelContext, TOOL_COUNT * 2); + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); + }); + + it("withholds the excluded operations and registers the rest", async () => { + const modelContext = installModelContext(document); + const logger = makeLogger(); + mountReady({ + webMCP: { + enabled: true, + exclude: ["submit", "deletePages", "movePage", "rotatePage"], + }, + logger, + }); + await waitForTools(modelContext, TOOL_COUNT - 4); + + const names = modelContext.registered.map((tool) => tool.name); + expect(names).toContain(toolName("setFieldValue")); + expect(names).toContain(toolName("getFields")); + expect(names).not.toContain(toolName("submit")); + expect(names).not.toContain(toolName("deletePages")); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("executes a tool call as the operation request on the wire and returns the editor Result, wire-shaped, as a JSON-text tool result", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + const pendingResult = findTool( + modelContext, + "simplepdf_embed_set_field_value" + ).execute({ field_id: "f1", value: "Jane" }); + const request = await waitForRequest(harness, "SET_FIELD_VALUE"); + expect(request.data).toEqual({ field_id: "f1", value: "Jane" }); + harness.reply(request, { success: true }); + const toolResult = await pendingResult; + expect(toolResult.isError).toBeUndefined(); + expect(toolResult.content).toEqual([ + { type: "text", text: JSON.stringify({ success: true, data: null }) }, + ]); + }); + + it("hands the agent the wire-shaped result its tool description promises (snake_case, not the SDK camelCase)", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + const pendingResult = findTool( + modelContext, + "simplepdf_embed_create_field" + ).execute({ type: "TEXT", x: 1, y: 2, width: 3, height: 4, page: 1 }); + const request = await waitForRequest(harness, "CREATE_FIELD"); + harness.reply(request, { success: true, data: { field_id: "f_new" } }); + const toolResult = await pendingResult; + expect(toolResult.content).toEqual([ + { + type: "text", + text: JSON.stringify({ success: true, data: { field_id: "f_new" } }), + }, + ]); + }); + + it("returns the annotated page render once, as an image block, with the badges map in the text block", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + const pendingResult = findTool( + modelContext, + "simplepdf_embed_get_annotated_page" + ).execute({ page: 1 }); + const request = await waitForRequest(harness, "GET_ANNOTATED_PAGE"); harness.reply(request, { success: true, - data: { page: 1, image_data_url: 'data:image/png;base64,iVBORw0KGgo=', image_width: 10, image_height: 12, badges: { '1': 'f1' } }, - }) - const toolResult = await pendingResult - expect(toolResult.isError).toBeUndefined() + data: { + page: 1, + image_data_url: "data:image/png;base64,iVBORw0KGgo=", + image_width: 10, + image_height: 12, + badges: { "1": "f1" }, + }, + }); + const toolResult = await pendingResult; + expect(toolResult.isError).toBeUndefined(); expect(toolResult.content).toEqual([ - { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, - { type: 'text', text: JSON.stringify({ success: true, data: { page: 1, image_width: 10, image_height: 12, badges: { '1': 'f1' } } }) }, - ]) - }) - - it('keeps the text envelope for a failed annotated page render', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - const pendingResult = findTool(modelContext, 'simplepdf_embed_get_annotated_page').execute({ page: 99 }) - const request = await waitForRequest(harness, 'GET_ANNOTATED_PAGE') - const failure = { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } } - harness.reply(request, failure) - const toolResult = await pendingResult - expect(toolResult.isError).toBe(true) - expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify(failure) }]) - }) - - it('flags a failed editor Result as an error tool result that still carries the error code', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - const pendingResult = findTool(modelContext, 'simplepdf_embed_go_to').execute({ page: 99 }) - const request = await waitForRequest(harness, 'GO_TO') - harness.reply(request, { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } }) - const toolResult = await pendingResult - expect(toolResult.isError).toBe(true) + { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + { + type: "text", + text: JSON.stringify({ + success: true, + data: { + page: 1, + image_width: 10, + image_height: 12, + badges: { "1": "f1" }, + }, + }), + }, + ]); + }); + + it("keeps the text envelope for a failed annotated page render", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + const pendingResult = findTool( + modelContext, + "simplepdf_embed_get_annotated_page" + ).execute({ page: 99 }); + const request = await waitForRequest(harness, "GET_ANNOTATED_PAGE"); + const failure = { + success: false, + error: { code: "bad_request:page_out_of_range", message: "no page 99" }, + }; + harness.reply(request, failure); + const toolResult = await pendingResult; + expect(toolResult.isError).toBe(true); + expect(toolResult.content).toEqual([ + { type: "text", text: JSON.stringify(failure) }, + ]); + }); + + it("flags a failed editor Result as an error tool result that still carries the error code", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + const pendingResult = findTool( + modelContext, + "simplepdf_embed_go_to" + ).execute({ page: 99 }); + const request = await waitForRequest(harness, "GO_TO"); + harness.reply(request, { + success: false, + error: { code: "bad_request:page_out_of_range", message: "no page 99" }, + }); + const toolResult = await pendingResult; + expect(toolResult.isError).toBe(true); expect(toolResult.content).toEqual([ { - type: 'text', - text: JSON.stringify({ success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } }), + type: "text", + text: JSON.stringify({ + success: false, + error: { + code: "bad_request:page_out_of_range", + message: "no page 99", + }, + }), }, - ]) - }) + ]); + }); - it('rejects a call whose signal is already aborted and posts nothing to the editor', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) + it("rejects a call whose signal is already aborted and posts nothing to the editor", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); - const aborted = new AbortController() - aborted.abort() - const postedBefore = harness.posted.length + const aborted = new AbortController(); + aborted.abort(); + const postedBefore = harness.posted.length; await expect( - findTool(modelContext, 'simplepdf_embed_submit').execute({ download_copy: false }, { signal: aborted.signal }), - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(harness.posted).toHaveLength(postedBefore) - }) - - it('sends an empty payload when a no-input tool is called without arguments', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - void findTool(modelContext, 'simplepdf_embed_detect_fields').execute(undefined) - const request = await waitForRequest(harness, 'DETECT_FIELDS') - expect(request.data).toEqual({}) - }) - - it('unregisters every tool when the embed is disposed', async () => { - const modelContext = installModelContext(document) - const harness = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) - - harness.embed.lifecycle.dispose() - expect(modelContext.liveToolNames()).toEqual([]) - }) - - it('registers nothing when the embed is disposed before the lazy module resolves', async () => { - const modelContext = installModelContext(document) - const disposedEarly = mountReady({ webMCP: { enabled: true } }) - disposedEarly.embed.lifecycle.dispose() + findTool(modelContext, "simplepdf_embed_submit").execute( + { download_copy: false }, + { signal: aborted.signal } + ) + ).rejects.toMatchObject({ name: "AbortError" }); + expect(harness.posted).toHaveLength(postedBefore); + }); + + it("sends an empty payload when a no-input tool is called without arguments", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + void findTool(modelContext, "simplepdf_embed_detect_fields").execute( + undefined + ); + const request = await waitForRequest(harness, "DETECT_FIELDS"); + expect(request.data).toEqual({}); + }); + + it("unregisters every tool when the embed is disposed", async () => { + const modelContext = installModelContext(document); + const harness = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); + + harness.embed.lifecycle.dispose(); + expect(modelContext.liveToolNames()).toEqual([]); + }); + + it("registers nothing when the embed is disposed before the lazy module resolves", async () => { + const modelContext = installModelContext(document); + const disposedEarly = mountReady({ webMCP: { enabled: true } }); + disposedEarly.embed.lifecycle.dispose(); // Control: a later embed on the same context registers its full set, proving the // early one's lazy load had every chance to run and registered nothing. - mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) - }) - - it('registers nothing when the option is off, even with a model context present', async () => { - const modelContext = installModelContext(document) - const registerTool = vi.spyOn(modelContext, 'registerTool') - mountReady({}) - mountReady({ webMCP: { enabled: false } }) + mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); + }); + + it("registers nothing when the option is off, even with a model context present", async () => { + const modelContext = installModelContext(document); + const registerTool = vi.spyOn(modelContext, "registerTool"); + mountReady({}); + mountReady({ webMCP: { enabled: false } }); // Control: a ready embed with the option on registers, proving the off ones had // the same chance and took none of it. - mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT) - }) - - it('lets the first embed on a page own each tool name and reports the collision for a second one', async () => { - const modelContext = installModelContext(document) - const logger = makeLogger() - const first = mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - - mountReady({ webMCP: { enabled: true }, logger }) + mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT); + }); + + it("lets the first embed on a page own each tool name and reports the collision for a second one", async () => { + const modelContext = installModelContext(document); + const logger = makeLogger(); + const first = mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + + mountReady({ webMCP: { enabled: true }, logger }); await vi.waitFor(() => - expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_submit' }), - ) - expect(logger.warn).toHaveBeenCalledTimes(TOOL_COUNT) - expect(modelContext.registered).toHaveLength(TOOL_COUNT) + expect(logger.warn).toHaveBeenCalledWith( + "webmcp.tool_already_registered", + { tool: "simplepdf_embed_submit" } + ) + ); + expect(logger.warn).toHaveBeenCalledTimes(TOOL_COUNT); + expect(modelContext.registered).toHaveLength(TOOL_COUNT); // Disposing the owner frees the names for the next embed. - first.embed.lifecycle.dispose() - mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT * 2) - }) + first.embed.lifecycle.dispose(); + mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT * 2); + }); - it('frees a rejected name only for its owner, so a later embed that took the name keeps it', async () => { + it("frees a rejected name only for its owner, so a later embed that took the name keeps it", async () => { // A: the runtime rejects `download`; A's abort must not later free a name it never owned. - const modelContext = installModelContext(document, { rejectTool: 'simplepdf_embed_download' }) - const first = mountReady({ webMCP: { enabled: true }, logger: makeLogger() }) - await waitForTools(modelContext, TOOL_COUNT - 1) + const modelContext = installModelContext(document, { + rejectTool: "simplepdf_embed_download", + }); + const first = mountReady({ + webMCP: { enabled: true }, + logger: makeLogger(), + }); + await waitForTools(modelContext, TOOL_COUNT - 1); // B: on an accepting context, takes `download` (the rest are reported as A's). - const accepting = installModelContext(document) - const second = mountReady({ webMCP: { enabled: true }, logger: makeLogger() }) - await waitForTools(accepting, 1) - expect(accepting.registered[0]?.name).toBe('simplepdf_embed_download') + const accepting = installModelContext(document); + const second = mountReady({ + webMCP: { enabled: true }, + logger: makeLogger(), + }); + await waitForTools(accepting, 1); + expect(accepting.registered[0]?.name).toBe("simplepdf_embed_download"); // A disposes: its names are freed for C, but B's `download` stays owned, so C is refused it. - first.embed.lifecycle.dispose() - const logger = makeLogger() - mountReady({ webMCP: { enabled: true }, logger }) - await waitForTools(accepting, TOOL_COUNT) - expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_download' }) - expect(logger.warn).toHaveBeenCalledTimes(1) - expect(accepting.registered.filter((tool) => tool.name === 'simplepdf_embed_download')).toHaveLength(1) - second.embed.lifecycle.dispose() - }) - - it('falls back to navigator.modelContext when the document exposes none', async () => { - const modelContext = installModelContext(navigator) - mountReady({ webMCP: { enabled: true } }) - await waitForTools(modelContext, TOOL_COUNT) - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) - }) - - it('reports an absent model context, never throws, and registers once a context appears', async () => { - const logger = makeLogger() - const harness = makeHarness({ webMCP: { enabled: true }, logger }) - harness.markEditorReady() - await vi.waitFor(() => expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'no_model_context' })) - expect(logger.error).not.toHaveBeenCalled() + first.embed.lifecycle.dispose(); + const logger = makeLogger(); + mountReady({ webMCP: { enabled: true }, logger }); + await waitForTools(accepting, TOOL_COUNT); + expect(logger.warn).toHaveBeenCalledWith("webmcp.tool_already_registered", { + tool: "simplepdf_embed_download", + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect( + accepting.registered.filter( + (tool) => tool.name === "simplepdf_embed_download" + ) + ).toHaveLength(1); + second.embed.lifecycle.dispose(); + }); + + it("falls back to navigator.modelContext when the document exposes none", async () => { + const modelContext = installModelContext(navigator); + mountReady({ webMCP: { enabled: true } }); + await waitForTools(modelContext, TOOL_COUNT); + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); + }); + + it("reports an absent model context, never throws, and registers once a context appears", async () => { + const logger = makeLogger(); + const harness = makeHarness({ webMCP: { enabled: true }, logger }); + harness.markEditorReady(); + await vi.waitFor(() => + expect(logger.info).toHaveBeenCalledWith("webmcp.unavailable", { + reason: "no_model_context", + }) + ); + expect(logger.error).not.toHaveBeenCalled(); // A context installed after a fast EDITOR_READY (an extension injected late) is // picked up on the next lifecycle transition. - const modelContext = installModelContext(document) - harness.markDocumentLoaded() - await waitForTools(modelContext, TOOL_COUNT) - }) - - it('reports a model context without registerTool as invalid and keeps probing, so a placeholder filled in later still gets the tools', async () => { - Object.defineProperty(document, 'modelContext', { configurable: true, value: {} }) - const logger = makeLogger() - const harness = mountReady({ webMCP: { enabled: true }, logger }) + const modelContext = installModelContext(document); + harness.markDocumentLoaded(); + await waitForTools(modelContext, TOOL_COUNT); + }); + + it("reports a model context without registerTool as invalid and keeps probing, so a placeholder filled in later still gets the tools", async () => { + Object.defineProperty(document, "modelContext", { + configurable: true, + value: {}, + }); + const logger = makeLogger(); + const harness = mountReady({ webMCP: { enabled: true }, logger }); await vi.waitFor(() => - expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), - ) - - const modelContext = installModelContext(document) - harness.markDocumentLoaded() - await waitForTools(modelContext, TOOL_COUNT) - }) - - it('keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name', async () => { - const modelContext = installModelContext(document, { rejectTool: 'simplepdf_embed_download' }) - const logger = makeLogger() - mountReady({ webMCP: { enabled: true }, logger }) - await waitForTools(modelContext, TOOL_COUNT - 1) - - expect(modelContext.registered.map((tool) => tool.name)).not.toContain('simplepdf_embed_download') + expect(logger.info).toHaveBeenCalledWith("webmcp.unavailable", { + reason: "invalid_model_context", + }) + ); + + const modelContext = installModelContext(document); + harness.markDocumentLoaded(); + await waitForTools(modelContext, TOOL_COUNT); + }); + + it("keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name", async () => { + const modelContext = installModelContext(document, { + rejectTool: "simplepdf_embed_download", + }); + const logger = makeLogger(); + mountReady({ webMCP: { enabled: true }, logger }); + await waitForTools(modelContext, TOOL_COUNT - 1); + + expect(modelContext.registered.map((tool) => tool.name)).not.toContain( + "simplepdf_embed_download" + ); await vi.waitFor(() => - expect(logger.error).toHaveBeenCalledWith('webmcp.register_tool_failed', { - tool: 'simplepdf_embed_download', - message: 'runtime rejected simplepdf_embed_download', - }), - ) - }) -}) + expect(logger.error).toHaveBeenCalledWith("webmcp.register_tool_failed", { + tool: "simplepdf_embed_download", + message: "runtime rejected simplepdf_embed_download", + }) + ); + }); +}); diff --git a/react/README.md b/react/README.md index d4b87292..274175f3 100644 --- a/react/README.md +++ b/react/README.md @@ -155,6 +155,7 @@ Actions are camelCase (the editor's snake_case wire is transformed for you). `us | `actions.deleteFields({ fieldIds?, page? })` | Delete fields by id or page, or all fields if both are omitted | | `actions.getDocumentContent({ extractionMode })` | Extract document content (`'auto'` or `'ocr'`) | | `actions.setFieldValue({ fieldId, value })` | Set a field's value | +| `actions.batchSetFieldValue({ updates })` | Set several fields at once (`updates: [{ fieldId, value }]`): all apply, or none do | | `actions.submit({ downloadCopy })` | Submit the document | …plus `createField`, `getFields`, `getAnnotatedPage`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, and `loadDocument`. All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index 5a78eec0..b1613a8a 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -408,6 +408,7 @@ export const useEmbed = (): { const actions = React.useMemo( () => ({ + batchSetFieldValue: (input) => embedRef.current?.batchSetFieldValue(input) ?? notMounted(), createField: (input) => embedRef.current?.createField(input) ?? notMounted(), deleteFields: (input) => embedRef.current?.deleteFields(input) ?? notMounted(), deletePages: (input) => embedRef.current?.deletePages(input) ?? notMounted(), From 878ec3dbbc81182288f80220ec0afba31c544eb6 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 20:16:05 +0200 Subject: [PATCH 02/10] chore: ship each tool description once and no source maps, restore the original formatting --- .changeset/smaller-packages.md | 10 + embed/scripts/check-bundle-size.mjs | 108 ++-- embed/scripts/generate.mjs | 763 ++++++++++-------------- embed/src/bridge.ts | 610 ++++++++----------- embed/src/generated/schemas.ts | 34 +- embed/src/tools.ts | 136 ++--- embed/src/types.ts | 116 ++-- embed/src/webmcp.ts | 198 +++--- embed/test/bridge.test.ts | 794 +++++++++++-------------- embed/test/case-transform.test.ts | 127 ++-- embed/test/helpers.ts | 15 +- embed/test/mount.test.ts | 721 +++++++++------------- embed/test/tanstack-ai.test.ts | 85 ++- embed/test/tools.test.ts | 104 ++-- embed/test/unwrap.test.ts | 40 +- embed/test/webmcp.test.ts | 892 +++++++++++----------------- embed/tsup.config.ts | 1 - react/rollup.config.js | 2 - react/tsconfig.json | 1 - 19 files changed, 1967 insertions(+), 2790 deletions(-) create mode 100644 .changeset/smaller-packages.md diff --git a/.changeset/smaller-packages.md b/.changeset/smaller-packages.md new file mode 100644 index 00000000..43e2427c --- /dev/null +++ b/.changeset/smaller-packages.md @@ -0,0 +1,10 @@ +--- +"@simplepdf/embed": minor +"@simplepdf/react-embed-pdf": patch +--- + +**Smaller installs and leaner agent requests.** + +- The agentic tools no longer repeat each tool's description inside its input schema: every model request through the AI SDK or TanStack AI tools sends about 3.7 KB less, and the tool entries are about 28% smaller. +- The packages no longer ship source maps: `@simplepdf/embed` unpacks to 263 KB instead of 645 KB, `@simplepdf/react-embed-pdf` to 41 KB instead of 113 KB. +- The `/schemas` input schemas no longer carry the operation description at their root; read it from `SIMPLEPDF_TOOLS[name].description`, or from `OPERATIONS` in `@simplepdf/embed/protocol` for every operation including `loadDocument`. diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index b5598f1c..893a068f 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -5,99 +5,77 @@ // the consumers that trigger it), so the two costs stay visible separately. Export loadability is guarded // separately by ../../scripts/check-exports.mjs (the `check:exports` script). -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { gzipSync } from "node:zlib"; -import { LAZY_CHUNKS } from "./lazy-chunks.mjs"; +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { gzipSync } from 'node:zlib' +import { LAZY_CHUNKS } from './lazy-chunks.mjs' -const DIST = join(dirname(fileURLToPath(import.meta.url)), "..", "dist"); +const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') // Gzip budget (bytes) per entry's local closure. Each cap sits 0.5–1.5 KB above the // measured size, so any non-trivial growth trips the gate and gets reviewed. // The zero-dep root carries the bridge + createEmbed (create + attach paths) + its // actionable config validation + the WebMCP opt-in hook. const BUDGETS = { - "index.js": 10 * 1024, - "protocol.js": 3.5 * 1024, - "schemas.js": 3 * 1024, - "tools.js": 6 * 1024, - "ai-sdk.js": 6.5 * 1024, - "tanstack-ai.js": 6.5 * 1024, -}; + 'index.js': 10 * 1024, + 'protocol.js': 3.5 * 1024, + 'schemas.js': 2 * 1024, + 'tools.js': 4.5 * 1024, + 'ai-sdk.js': 5 * 1024, + 'tanstack-ai.js': 5 * 1024, +} const importsOf = (file, pattern) => { - const content = readFileSync(join(DIST, file), "utf8"); - return [...content.matchAll(pattern)].map((match) => - match[1].replace(/^\.\//, "") - ); -}; -const localImports = (file) => importsOf(file, /from\s*['"](\.\/[^'"]+)['"]/g); -const lazyImports = (file) => - importsOf(file, /import\(['"](\.\/[^'"]+)['"]\)/g); + const content = readFileSync(join(DIST, file), 'utf8') + return [...content.matchAll(pattern)].map((match) => match[1].replace(/^\.\//, '')) +} +const localImports = (file) => importsOf(file, /from\s*['"](\.\/[^'"]+)['"]/g) +const lazyImports = (file) => importsOf(file, /import\(['"](\.\/[^'"]+)['"]\)/g) const closureOf = (entry) => { - const seen = new Set(); + const seen = new Set() const walk = (file) => { if (seen.has(file) || !existsSync(join(DIST, file))) { - return; + return } - seen.add(file); + seen.add(file) for (const dependency of localImports(file)) { - walk(dependency); + walk(dependency) } - }; - walk(entry); - return [...seen]; -}; + } + walk(entry) + return [...seen] +} const gzipBytes = (files) => - files.reduce( - (total, file) => total + gzipSync(readFileSync(join(DIST, file))).length, - 0 - ); + files.reduce((total, file) => total + gzipSync(readFileSync(join(DIST, file))).length, 0) const checkBudget = (entry, budget) => { - const size = gzipBytes(closureOf(entry)); - const ok = size <= budget; - console.log( - `${ok ? "✓" : "✗"} ${entry}: ${size} B gzip (budget ${budget} B)` - ); - return ok; -}; + const size = gzipBytes(closureOf(entry)) + const ok = size <= budget + console.log(`${ok ? '✓' : '✗'} ${entry}: ${size} B gzip (budget ${budget} B)`) + return ok +} const entriesWithinBudget = Object.entries(BUDGETS).map(([entry, budget]) => { if (!existsSync(join(DIST, entry))) { - console.error( - `✗ ${entry}: missing from dist (run \`npm run build\` first)` - ); - return false; + console.error(`✗ ${entry}: missing from dist (run \`npm run build\` first)`) + return false } - return checkBudget(entry, budget); -}); + return checkBudget(entry, budget) +}) // Every lazy chunk an entry references must be built and budgeted; a lazy import // with no budget row is an unmeasured download. -const lazyChunks = [ - ...new Set( - Object.keys(BUDGETS).flatMap((entry) => - closureOf(entry).flatMap(lazyImports) - ) - ), -]; +const lazyChunks = [...new Set(Object.keys(BUDGETS).flatMap((entry) => closureOf(entry).flatMap(lazyImports)))] const lazyWithinBudget = lazyChunks.map((chunk) => { - const lazyChunk = Object.entries(LAZY_CHUNKS).find(([prefix]) => - chunk.startsWith(prefix) - ); + const lazyChunk = Object.entries(LAZY_CHUNKS).find(([prefix]) => chunk.startsWith(prefix)) if (lazyChunk === undefined || !existsSync(join(DIST, chunk))) { - console.error( - `✗ ${chunk}: lazily imported but not built or not budgeted (add a row to lazy-chunks.mjs)` - ); - return false; + console.error(`✗ ${chunk}: lazily imported but not built or not budgeted (add a row to lazy-chunks.mjs)`) + return false } - return checkBudget(chunk, lazyChunk[1].budgetBytes); -}); + return checkBudget(chunk, lazyChunk[1].budgetBytes) +}) -process.exit( - [...entriesWithinBudget, ...lazyWithinBudget].every(Boolean) ? 0 : 1 -); +process.exit([...entriesWithinBudget, ...lazyWithinBudget].every(Boolean) ? 0 : 1) diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index b37aec44..16c1714a 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -22,26 +22,24 @@ // integer/number/boolean/null/array/enum/const/anyOf), so the emitter below covers // it exhaustively rather than pulling a general json-schema-to-x dependency. -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' -const HERE = dirname(fileURLToPath(import.meta.url)); -const PKG_ROOT = join(HERE, ".."); -const GENERATED_DIR = join(PKG_ROOT, "src", "generated"); +const HERE = dirname(fileURLToPath(import.meta.url)) +const PKG_ROOT = join(HERE, '..') +const GENERATED_DIR = join(PKG_ROOT, 'src', 'generated') -const contract = JSON.parse( - readFileSync(join(PKG_ROOT, "embed-api.json"), "utf8") -); +const contract = JSON.parse(readFileSync(join(PKG_ROOT, 'embed-api.json'), 'utf8')) // Join lines with exactly one trailing newline (no trailing blank lines that // `git diff --check` would flag). -const renderFile = (lines) => `${lines.join("\n").replace(/\n+$/, "")}\n`; +const renderFile = (lines) => `${lines.join('\n').replace(/\n+$/, '')}\n` // Operations withheld from the /tools, /ai-sdk and /tanstack-ai subpaths (the WebMCP // surface registers all of them, like the editor): load_document is a host/setup action // there (the contract description says so). -const NON_AGENTIC_OPERATIONS = new Set(["load_document"]); +const NON_AGENTIC_OPERATIONS = new Set(['load_document']) // --------------------------------------------------------------------------- // Naming helpers @@ -51,18 +49,18 @@ const NON_AGENTIC_OPERATIONS = new Set(["load_document"]); * Used for method/tool names AND object property keys: the SDK surface is camelCase, * while the wire stays snake_case (the bridge transforms between them). */ const toCamel = (name) => - name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase()) /** snake_case request_type -> PascalCase type stem (go_to -> GoTo). */ const toPascal = (requestType) => { - const camel = toCamel(requestType); - return camel.charAt(0).toUpperCase() + camel.slice(1); -}; + const camel = toCamel(requestType) + return camel.charAt(0).toUpperCase() + camel.slice(1) +} // Known enum sets mapped to named types for a readable generated surface. // Comparison is order-insensitive on the enum members. -const enumSignature = (members) => [...members].sort().join("|"); -const NAMED_ENUMS = new Map(); +const enumSignature = (members) => [...members].sort().join('|') +const NAMED_ENUMS = new Map() // --------------------------------------------------------------------------- // JSON Schema -> TypeScript type @@ -74,216 +72,193 @@ const NAMED_ENUMS = new Map(); // generated types/schemas. `additionalProperties` is honored only as a schema on // an object with no `properties` (a map: Record / z.record). const KNOWN_SCHEMA_KEYWORDS = new Set([ - "type", - "enum", - "const", - "anyOf", - "properties", - "additionalProperties", - "required", - "items", - "description", -]); + 'type', + 'enum', + 'const', + 'anyOf', + 'properties', + 'additionalProperties', + 'required', + 'items', + 'description', +]) const assertKnownKeywords = (node) => { for (const keyword of Object.keys(node)) { if (!KNOWN_SCHEMA_KEYWORDS.has(keyword)) { throw new Error( - `Unsupported JSON Schema keyword '${keyword}' in ${JSON.stringify( - node - )} — extend the generator to honor it` - ); + `Unsupported JSON Schema keyword '${keyword}' in ${JSON.stringify(node)} — extend the generator to honor it`, + ) } } if (node.additionalProperties !== undefined && !isMapNode(node)) { throw new Error( - `Unsupported 'additionalProperties' in ${JSON.stringify( - node - )} — only a schema on an object without 'properties' is honored (a map)` - ); + `Unsupported 'additionalProperties' in ${JSON.stringify(node)} — only a schema on an object without 'properties' is honored (a map)`, + ) } if (isMapNode(node) && node.required !== undefined) { - throw new Error( - `Unsupported 'required' on a map node ${JSON.stringify( - node - )} — a map has no fixed keys to require` - ); + throw new Error(`Unsupported 'required' on a map node ${JSON.stringify(node)} — a map has no fixed keys to require`) } -}; +} // An object whose every key maps to one value schema (`{ additionalProperties: }` // with no `properties`): emitted as Record / z.record. const isMapNode = (node) => - node.type === "object" && + node.type === 'object' && node.properties === undefined && - typeof node.additionalProperties === "object" && - node.additionalProperties !== null; + typeof node.additionalProperties === 'object' && + node.additionalProperties !== null // Recursively assert every node in a schema tree carries only known keywords, so // a new constraint anywhere in the manifest (op I/O, events, the error schema, or // the protocol envelopes) fails the build instead of being silently ignored. const preflightSchema = (node) => { - if (typeof node !== "object" || node === null) { - return; + if (typeof node !== 'object' || node === null) { + return } - assertKnownKeywords(node); + assertKnownKeywords(node) if (node.properties !== undefined) { for (const sub of Object.values(node.properties)) { - preflightSchema(sub); + preflightSchema(sub) } } if (node.items !== undefined) { - preflightSchema(node.items); + preflightSchema(node.items) } if (isMapNode(node)) { - preflightSchema(node.additionalProperties); + preflightSchema(node.additionalProperties) } if (Array.isArray(node.anyOf)) { for (const sub of node.anyOf) { - preflightSchema(sub); + preflightSchema(sub) } } -}; +} const tsForEnum = (members) => { - const named = NAMED_ENUMS.get(enumSignature(members)); + const named = NAMED_ENUMS.get(enumSignature(members)) if (named !== undefined) { - return named; + return named } - return members.map((m) => JSON.stringify(m)).join(" | "); -}; + return members.map((m) => JSON.stringify(m)).join(' | ') +} // `camelKeys` controls property-key casing: true for OPERATION payloads (the SDK's // camelCase method args / results — the bridge transforms them to snake on the wire), // false for EVENT payloads (forwarded to onEmbedEvent VERBATIM, so they keep the // editor's snake_case to stay compatible with the 1.x EmbedEvent contract). const tsForNode = (node, camelKeys) => { - assertKnownKeywords(node); + assertKnownKeywords(node) if (node.const !== undefined) { - return JSON.stringify(node.const); + return JSON.stringify(node.const) } if (Array.isArray(node.enum)) { - return tsForEnum(node.enum); + return tsForEnum(node.enum) } if (Array.isArray(node.anyOf)) { - return node.anyOf.map((sub) => tsForNode(sub, camelKeys)).join(" | "); + return node.anyOf.map((sub) => tsForNode(sub, camelKeys)).join(' | ') } switch (node.type) { - case "string": - return "string"; - case "integer": - case "number": - return "number"; - case "boolean": - return "boolean"; - case "null": - return "null"; - case "array": { - const item = tsForNode(node.items, camelKeys); - return /[ |]/.test(item) ? `Array<${item}>` : `${item}[]`; + case 'string': + return 'string' + case 'integer': + case 'number': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + case 'array': { + const item = tsForNode(node.items, camelKeys) + return /[ |]/.test(item) ? `Array<${item}>` : `${item}[]` } - case "object": - return tsForObject(node, camelKeys); + case 'object': + return tsForObject(node, camelKeys) default: - throw new Error( - `Unsupported JSON Schema node for TS: ${JSON.stringify(node)}` - ); + throw new Error(`Unsupported JSON Schema node for TS: ${JSON.stringify(node)}`) } -}; +} const tsForObject = (node, camelKeys) => { if (isMapNode(node)) { - return `Record`; + return `Record` } - const properties = node.properties ?? {}; - const required = new Set(node.required ?? []); - const keys = Object.keys(properties); + const properties = node.properties ?? {} + const required = new Set(node.required ?? []) + const keys = Object.keys(properties) if (keys.length === 0) { - return "Record"; + return 'Record' } const members = keys.map((key) => { - const optional = required.has(key) ? "" : "?"; - return `${camelKeys ? toCamel(key) : key}${optional}: ${tsForNode( - properties[key], - camelKeys - )}`; - }); - return `{ ${members.join("; ")} }`; -}; + const optional = required.has(key) ? '' : '?' + return `${camelKeys ? toCamel(key) : key}${optional}: ${tsForNode(properties[key], camelKeys)}` + }) + return `{ ${members.join('; ')} }` +} // --------------------------------------------------------------------------- // JSON Schema -> zod expression // --------------------------------------------------------------------------- const describe = (expr, node) => - typeof node.description === "string" - ? `${expr}.describe(${JSON.stringify(node.description)})` - : expr; + typeof node.description === 'string' ? `${expr}.describe(${JSON.stringify(node.description)})` : expr const zodForNode = (node, { withDescription }) => { - assertKnownKeywords(node); + assertKnownKeywords(node) const base = (() => { if (node.const !== undefined) { - return `z.literal(${JSON.stringify(node.const)})`; + return `z.literal(${JSON.stringify(node.const)})` } if (Array.isArray(node.enum)) { - return `z.enum([${node.enum.map((m) => JSON.stringify(m)).join(", ")}])`; + return `z.enum([${node.enum.map((m) => JSON.stringify(m)).join(', ')}])` } if (Array.isArray(node.anyOf)) { // Collapse the common `{...} | { type: 'null' }` shape into `.nullable()`. - const nonNull = node.anyOf.filter((sub) => sub.type !== "null"); - const hasNull = node.anyOf.some((sub) => sub.type === "null"); + const nonNull = node.anyOf.filter((sub) => sub.type !== 'null') + const hasNull = node.anyOf.some((sub) => sub.type === 'null') if (hasNull && nonNull.length === 1) { - return `${zodForNode(nonNull[0], { - withDescription: false, - })}.nullable()`; + return `${zodForNode(nonNull[0], { withDescription: false })}.nullable()` } - return `z.union([${node.anyOf - .map((sub) => zodForNode(sub, { withDescription: false })) - .join(", ")}])`; + return `z.union([${node.anyOf.map((sub) => zodForNode(sub, { withDescription: false })).join(', ')}])` } switch (node.type) { - case "string": - return "z.string()"; - case "integer": - return "z.number().int()"; - case "number": - return "z.number()"; - case "boolean": - return "z.boolean()"; - case "null": - return "z.null()"; - case "array": - return `z.array(${zodForNode(node.items, { withDescription: false })})`; - case "object": - return zodForObject(node); + case 'string': + return 'z.string()' + case 'integer': + return 'z.number().int()' + case 'number': + return 'z.number()' + case 'boolean': + return 'z.boolean()' + case 'null': + return 'z.null()' + case 'array': + return `z.array(${zodForNode(node.items, { withDescription: false })})` + case 'object': + return zodForObject(node) default: - throw new Error( - `Unsupported JSON Schema node for zod: ${JSON.stringify(node)}` - ); + throw new Error(`Unsupported JSON Schema node for zod: ${JSON.stringify(node)}`) } - })(); - return withDescription ? describe(base, node) : base; -}; + })() + return withDescription ? describe(base, node) : base +} const zodForObject = (node) => { if (isMapNode(node)) { - return `z.record(z.string(), ${zodForNode(node.additionalProperties, { - withDescription: false, - })})`; + return `z.record(z.string(), ${zodForNode(node.additionalProperties, { withDescription: false })})` } - const properties = node.properties ?? {}; - const required = new Set(node.required ?? []); - const keys = Object.keys(properties); + const properties = node.properties ?? {} + const required = new Set(node.required ?? []) + const keys = Object.keys(properties) if (keys.length === 0) { - return "z.object({})"; + return 'z.object({})' } const members = keys.map((key) => { - const inner = zodForNode(properties[key], { withDescription: true }); - const withOptional = required.has(key) ? inner : `${inner}.optional()`; - return ` ${toCamel(key)}: ${withOptional},`; - }); - return `z.object({\n${members.join("\n")}\n})`; -}; + const inner = zodForNode(properties[key], { withDescription: true }) + const withOptional = required.has(key) ? inner : `${inner}.optional()` + return ` ${toCamel(key)}: ${withOptional},` + }) + return `z.object({\n${members.join('\n')}\n})` +} // --------------------------------------------------------------------------- // Extract the closed editor-error code set from editor_error_schema. @@ -292,25 +267,25 @@ const zodForObject = (node) => { // --------------------------------------------------------------------------- const collectErrorCodes = (schema) => { - const codes = new Set(); + const codes = new Set() const visitCodeNode = (codeNode) => { - if (typeof codeNode.const === "string") { - codes.add(codeNode.const); + if (typeof codeNode.const === 'string') { + codes.add(codeNode.const) } if (Array.isArray(codeNode.anyOf)) { for (const sub of codeNode.anyOf) { - visitCodeNode(sub); + visitCodeNode(sub) } } - }; + } for (const variant of schema.anyOf ?? []) { - const codeNode = variant.properties?.code; + const codeNode = variant.properties?.code if (codeNode !== undefined) { - visitCodeNode(codeNode); + visitCodeNode(codeNode) } } - return [...codes].sort(); -}; + return [...codes].sort() +} // --------------------------------------------------------------------------- // Resolve the named enum aliases against the real contract, then build outputs. @@ -318,439 +293,299 @@ const collectErrorCodes = (schema) => { // Fail-loud preflight over every JSON Schema root in the manifest. for (const op of contract.operations) { - preflightSchema(op.input_schema); - preflightSchema(op.output_schema); + preflightSchema(op.input_schema) + preflightSchema(op.output_schema) } for (const event of contract.events) { - preflightSchema(event.payload_schema); + preflightSchema(event.payload_schema) } -preflightSchema(contract.editor_error_schema); -preflightSchema(contract.protocol.request_envelope_schema); -preflightSchema(contract.protocol.result_envelope_schema); +preflightSchema(contract.editor_error_schema) +preflightSchema(contract.protocol.request_envelope_schema) +preflightSchema(contract.protocol.result_envelope_schema) // Keyed by lowercase request_type so the hardcoded lookups below are agnostic to // the manifest's request_type casing (snake_case or SCREAMING_SNAKE). const operationsByType = Object.fromEntries( - contract.operations.map((op) => [op.request_type.toLowerCase(), op]) -); + contract.operations.map((op) => [op.request_type.toLowerCase(), op]), +) -const fieldRecordSchema = - operationsByType.get_fields.output_schema.properties.fields.items; -const fieldTypes = fieldRecordSchema.properties.type.enum; -const overlayToolTypes = - operationsByType.create_field.input_schema.properties.type.enum; -const extractionModes = - operationsByType.get_document_content.input_schema.properties.extraction_mode - .enum; +const fieldRecordSchema = operationsByType.get_fields.output_schema.properties.fields.items +const fieldTypes = fieldRecordSchema.properties.type.enum +const overlayToolTypes = operationsByType.create_field.input_schema.properties.type.enum +const extractionModes = operationsByType.get_document_content.input_schema.properties.extraction_mode.enum -NAMED_ENUMS.set(enumSignature(fieldTypes), "FieldType"); -NAMED_ENUMS.set(enumSignature(overlayToolTypes), "OverlayToolType"); -NAMED_ENUMS.set(enumSignature(extractionModes), "ExtractionMode"); -NAMED_ENUMS.set(enumSignature(contract.locales), "Locale"); +NAMED_ENUMS.set(enumSignature(fieldTypes), 'FieldType') +NAMED_ENUMS.set(enumSignature(overlayToolTypes), 'OverlayToolType') +NAMED_ENUMS.set(enumSignature(extractionModes), 'ExtractionMode') +NAMED_ENUMS.set(enumSignature(contract.locales), 'Locale') -const editorErrorCodes = collectErrorCodes(contract.editor_error_schema); +const editorErrorCodes = collectErrorCodes(contract.editor_error_schema) const constArray = (name, values, typeName) => { - const literals = values.map((v) => JSON.stringify(v)).join(", "); + const literals = values.map((v) => JSON.stringify(v)).join(', ') return ( `export const ${name} = [${literals}] as const\n` + `export type ${typeName} = (typeof ${name})[number]\n` - ); -}; + ) +} // --- contract.ts (zero runtime deps) --------------------------------------- -const contractLines = []; -contractLines.push( - "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." -); -contractLines.push( - "// Zero runtime dependencies: the zero-dep root imports only from this module and method-names.ts." -); -contractLines.push("import type { METHOD_NAMES } from './method-names'"); -contractLines.push(""); -contractLines.push(constArray("LOCALES", contract.locales, "Locale")); -contractLines.push( - constArray("EDITOR_ERROR_CODES", editorErrorCodes, "EditorErrorCode") -); -contractLines.push(constArray("FIELD_TYPES", fieldTypes, "FieldType")); -contractLines.push( - constArray("OVERLAY_TOOL_TYPES", overlayToolTypes, "OverlayToolType") -); -contractLines.push( - constArray("EXTRACTION_MODES", extractionModes, "ExtractionMode") -); +const contractLines = [] +contractLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +contractLines.push('// Zero runtime dependencies: the zero-dep root imports only from this module and method-names.ts.') +contractLines.push("import type { METHOD_NAMES } from './method-names'") +contractLines.push('') +contractLines.push(constArray('LOCALES', contract.locales, 'Locale')) +contractLines.push(constArray('EDITOR_ERROR_CODES', editorErrorCodes, 'EditorErrorCode')) +contractLines.push(constArray('FIELD_TYPES', fieldTypes, 'FieldType')) +contractLines.push(constArray('OVERLAY_TOOL_TYPES', overlayToolTypes, 'OverlayToolType')) +contractLines.push(constArray('EXTRACTION_MODES', extractionModes, 'ExtractionMode')) // Per-operation input/output types. for (const op of contract.operations) { - const stem = toPascal(op.request_type); - contractLines.push( - `export type ${stem}Input = ${tsForNode(op.input_schema, true)}` - ); - contractLines.push( - `export type ${stem}Output = ${tsForNode(op.output_schema, true)}` - ); + const stem = toPascal(op.request_type) + contractLines.push(`export type ${stem}Input = ${tsForNode(op.input_schema, true)}`) + contractLines.push(`export type ${stem}Output = ${tsForNode(op.output_schema, true)}`) } -contractLines.push(""); +contractLines.push('') // Ergonomic aliases over the get_document_content output shape. -contractLines.push( - "export type DocumentContentResult = GetDocumentContentOutput" -); -contractLines.push( - "export type DocumentContentPage = GetDocumentContentOutput['pages'][number]" -); -contractLines.push(""); +contractLines.push('export type DocumentContentResult = GetDocumentContentOutput') +contractLines.push("export type DocumentContentPage = GetDocumentContentOutput['pages'][number]") +contractLines.push('') // The typed `details` payload of every error code that carries one, derived from // editor_error_schema so the package's BridgeError never restates it: one // `Details` type per code ('bad_request:missing_required_fields' -> // MissingRequiredFieldsDetails) and the code -> details map BridgeError derives from. const detailedErrorVariants = (contract.editor_error_schema.anyOf ?? []).filter( - (variant) => - typeof variant.properties?.code?.const === "string" && - variant.properties?.details !== undefined -); -if ( - !detailedErrorVariants.some( - (variant) => - variant.properties.code.const === "bad_request:missing_required_fields" - ) -) { - throw new Error( - "embed-api.json: the bad_request:missing_required_fields variant lacks a details schema" - ); + (variant) => typeof variant.properties?.code?.const === 'string' && variant.properties?.details !== undefined, +) +if (!detailedErrorVariants.some((variant) => variant.properties.code.const === 'bad_request:missing_required_fields')) { + throw new Error('embed-api.json: the bad_request:missing_required_fields variant lacks a details schema') } -const toDetailsTypeName = (code) => - `${code - .split(":")[1] - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join("")}Details`; +const toDetailsTypeName = (code) => `${toPascal(code.split(':')[1])}Details` for (const variant of detailedErrorVariants) { contractLines.push( - `export type ${toDetailsTypeName( - variant.properties.code.const - )} = ${tsForNode(variant.properties.details, true)}` - ); + `export type ${toDetailsTypeName(variant.properties.code.const)} = ${tsForNode(variant.properties.details, true)}`, + ) } -contractLines.push("export type EditorErrorDetailsByCode = {"); +contractLines.push('export type EditorErrorDetailsByCode = {') for (const variant of detailedErrorVariants) { - contractLines.push( - ` '${variant.properties.code.const}': ${toDetailsTypeName( - variant.properties.code.const - )}` - ); + contractLines.push(` '${variant.properties.code.const}': ${toDetailsTypeName(variant.properties.code.const)}`) } -contractLines.push("}"); -contractLines.push(""); +contractLines.push('}') +contractLines.push('') // Outbound event payload types. for (const event of contract.events) { const stem = event.event_type .toLowerCase() .replace(/_([a-z])/g, (_, c) => c.toUpperCase()) - .replace(/^[a-z]/, (c) => c.toUpperCase()); - contractLines.push( - `export type ${stem}Payload = ${tsForNode(event.payload_schema, false)}` - ); + .replace(/^[a-z]/, (c) => c.toUpperCase()) + contractLines.push(`export type ${stem}Payload = ${tsForNode(event.payload_schema, false)}`) } -contractLines.push(""); +contractLines.push('') // Operation metadata table (the camelCase `method` is the SDK method name, also the /tools tool name). const opMeta = contract.operations.map((op) => { - const stem = toPascal(op.request_type); + const stem = toPascal(op.request_type) return ( ` {\n` + ` request_type: ${JSON.stringify(op.request_type)},\n` + ` wire_type: ${JSON.stringify(op.request_type.toUpperCase())},\n` + ` method: ${JSON.stringify(toCamel(op.request_type))},\n` + ` description: ${JSON.stringify(op.description)},\n` + - ` error_codes: [${op.error_codes - .map((c) => JSON.stringify(c)) - .join(", ")}] as const,\n` + - ` is_agentic_tool: ${!NON_AGENTIC_OPERATIONS.has( - op.request_type.toLowerCase() - )},\n` + - ` has_output: ${op.output_schema.type !== "null"},\n` + + ` error_codes: [${op.error_codes.map((c) => JSON.stringify(c)).join(', ')}] as const,\n` + + ` is_agentic_tool: ${!NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())},\n` + + ` has_output: ${op.output_schema.type !== 'null'},\n` + ` } /* ${stem} */` - ); -}); -contractLines.push( - `export const OPERATIONS = [\n${opMeta.join(",\n")},\n] as const` -); -contractLines.push(""); -contractLines.push( - 'export type WireType = (typeof OPERATIONS)[number]["wire_type"]' -); -contractLines.push( - 'export type RequestType = (typeof OPERATIONS)[number]["request_type"]' -); + ) +}) +contractLines.push(`export const OPERATIONS = [\n${opMeta.join(',\n')},\n] as const`) +contractLines.push('') +contractLines.push('export type WireType = (typeof OPERATIONS)[number]["wire_type"]') +contractLines.push('export type RequestType = (typeof OPERATIONS)[number]["request_type"]') // The JS method name is the camelCase of the wire op (the SDK is camelCase; the // bridge transforms to the snake_case wire). The names live in their own tiny module // (createEmbed validates an untyped caller's `webMCP.exclude` against the runtime // list, and must not pull this whole table into the zero-dep root); the type derives // from it here, and drift.ts pins it to the OPERATIONS methods (and IframeActions to // it) so the views of one fact cannot diverge. -contractLines.push("export type MethodName = (typeof METHOD_NAMES)[number]"); -contractLines.push(""); +contractLines.push('export type MethodName = (typeof METHOD_NAMES)[number]') +contractLines.push('') const eventMeta = contract.events.map( (event) => - ` { event_type: ${JSON.stringify( - event.event_type - )}, description: ${JSON.stringify(event.description)} }` -); -contractLines.push( - `export const OUTBOUND_EVENTS = [\n${eventMeta.join(",\n")},\n] as const` -); -contractLines.push( - 'export type OutboundEventType = (typeof OUTBOUND_EVENTS)[number]["event_type"]' -); -contractLines.push(""); - -mkdirSync(GENERATED_DIR, { recursive: true }); -writeFileSync(join(GENERATED_DIR, "contract.ts"), renderFile(contractLines)); + ` { event_type: ${JSON.stringify(event.event_type)}, description: ${JSON.stringify(event.description)} }`, +) +contractLines.push(`export const OUTBOUND_EVENTS = [\n${eventMeta.join(',\n')},\n] as const`) +contractLines.push('export type OutboundEventType = (typeof OUTBOUND_EVENTS)[number]["event_type"]') +contractLines.push('') + +mkdirSync(GENERATED_DIR, { recursive: true }) +writeFileSync(join(GENERATED_DIR, 'contract.ts'), renderFile(contractLines)) // --- schemas.ts (zod peer dep + compile-time drift guards) ----------------- -const schemaLines = []; -schemaLines.push( - "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." -); -schemaLines.push("import { z } from 'zod'"); -schemaLines.push(""); +const schemaLines = [] +schemaLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +schemaLines.push("import { z } from 'zod'") +schemaLines.push('') for (const op of contract.operations) { - const stem = toPascal(op.request_type); - schemaLines.push( - `export const ${stem}Input = ${zodForNode(op.input_schema, { - withDescription: true, - })}` - ); + const stem = toPascal(op.request_type) + // The operation description lives on the tool (tools.ts); a root `.describe()` would + // ship it twice and send it twice to the model in every tool's JSON schema. + schemaLines.push(`export const ${stem}Input = ${zodForNode(op.input_schema, { withDescription: false })}`) // Value + type share the PascalCase name (the canonical zod idiom). - schemaLines.push(`export type ${stem}Input = z.infer`); + schemaLines.push(`export type ${stem}Input = z.infer`) } -schemaLines.push(""); +schemaLines.push('') -writeFileSync(join(GENERATED_DIR, "schemas.ts"), renderFile(schemaLines)); +writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) // --- method-names.ts (zero runtime deps; the one generated value the root imports) --- -const methodNames = contract.operations.map((op) => toCamel(op.request_type)); +const methodNames = contract.operations.map((op) => toCamel(op.request_type)) writeFileSync( - join(GENERATED_DIR, "method-names.ts"), + join(GENERATED_DIR, 'method-names.ts'), renderFile([ - "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.", - "// The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list", - "// without pulling the operations table into the zero-dep root; contract.ts derives", - "// MethodName from this list.", - `export const METHOD_NAMES = [${methodNames - .map((name) => JSON.stringify(name)) - .join(", ")}] as const`, - ]) -); + '// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.', + '// The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list', + '// without pulling the operations table into the zero-dep root; contract.ts derives', + '// MethodName from this list.', + `export const METHOD_NAMES = [${methodNames.map((name) => JSON.stringify(name)).join(', ')}] as const`, + ]), +) // --- webmcp-tools.ts (zero runtime deps, loaded only by the WebMCP module) --- // The manifest's `tool` record per operation, verbatim: the same record the editor // registers on its own page. The generator only checks the record's shape (a new key // or hint fails loud) and renames `input_schema` to WebMCP's `inputSchema`. -const WEBMCP_ANNOTATION_KEYS = new Set([ - "destructiveHint", - "openWorldHint", - "readOnlyHint", - "untrustedContentHint", -]); +const WEBMCP_ANNOTATION_KEYS = new Set(['destructiveHint', 'openWorldHint', 'readOnlyHint', 'untrustedContentHint']) const webmcpToolRecord = (op) => { - const tool = op.tool; - if (typeof tool !== "object" || tool === null) { - throw new Error(`Operation ${op.request_type} publishes no tool record`); + const tool = op.tool + if (typeof tool !== 'object' || tool === null) { + throw new Error(`Operation ${op.request_type} publishes no tool record`) } - const { - name, - description, - input_schema: inputSchema, - annotations, - ...unknownKeys - } = tool; + const { name, description, input_schema: inputSchema, annotations, ...unknownKeys } = tool if (Object.keys(unknownKeys).length > 0) { throw new Error( - `Unsupported tool record keys on ${op.request_type}: ${Object.keys( - unknownKeys - ).join(", ")} — extend the generator to honor them` - ); + `Unsupported tool record keys on ${op.request_type}: ${Object.keys(unknownKeys).join(', ')} — extend the generator to honor them`, + ) } const isWellFormed = - typeof name === "string" && - typeof description === "string" && - inputSchema?.type === "object" && - typeof annotations === "object" && - annotations !== null; + typeof name === 'string' && + typeof description === 'string' && + inputSchema?.type === 'object' && + typeof annotations === 'object' && + annotations !== null if (!isWellFormed) { - throw new Error( - `Malformed tool record on ${op.request_type}: ${JSON.stringify(tool)}` - ); + throw new Error(`Malformed tool record on ${op.request_type}: ${JSON.stringify(tool)}`) } for (const hint of Object.keys(annotations)) { if (!WEBMCP_ANNOTATION_KEYS.has(hint)) { - throw new Error( - `Unsupported tool annotation '${hint}' on ${op.request_type} — extend the generator to honor it` - ); + throw new Error(`Unsupported tool annotation '${hint}' on ${op.request_type} — extend the generator to honor it`) } } - return { - name, - description, - inputSchema, - annotations, - wireType: op.request_type.toUpperCase(), - }; -}; - -const webmcpToolLines = []; -webmcpToolLines.push( - "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." -); -webmcpToolLines.push( - "// The WebMCP tool each operation publishes (the manifest `tool`: name, description," -); -webmcpToolLines.push( - "// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude`" -); -webmcpToolLines.push( - "// maps straight onto it. The editor registers the same record on its own page. Read" -); -webmcpToolLines.push( - "// only by src/webmcp.ts, which is lazy-loaded, so this table never lands in an entry" -); -webmcpToolLines.push( - "// that did not opt in. `wireType` is the operation the record dispatches to, carried" -); -webmcpToolLines.push( - "// here so the lazy module needs nothing from the OPERATIONS table." -); -webmcpToolLines.push("import type { MethodName, WireType } from './contract'"); -webmcpToolLines.push(""); -webmcpToolLines.push("export type WebMCPToolRecord = {"); -webmcpToolLines.push(" readonly wireType: WireType"); -webmcpToolLines.push(" readonly name: string"); -webmcpToolLines.push(" readonly description: string"); -webmcpToolLines.push(" readonly inputSchema: {"); -webmcpToolLines.push(" readonly type: 'object'"); -webmcpToolLines.push( - " readonly properties?: Readonly>" -); -webmcpToolLines.push(" readonly required?: readonly string[]"); -webmcpToolLines.push(" }"); -webmcpToolLines.push(" readonly annotations: {"); + return { name, description, inputSchema, annotations, wireType: op.request_type.toUpperCase() } +} + +const webmcpToolLines = [] +webmcpToolLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +webmcpToolLines.push('// The WebMCP tool each operation publishes (the manifest `tool`: name, description,') +webmcpToolLines.push('// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude`') +webmcpToolLines.push('// maps straight onto it. The editor registers the same record on its own page. Read') +webmcpToolLines.push('// only by src/webmcp.ts, which is lazy-loaded, so this table never lands in an entry') +webmcpToolLines.push('// that did not opt in. `wireType` is the operation the record dispatches to, carried') +webmcpToolLines.push('// here so the lazy module needs nothing from the OPERATIONS table.') +webmcpToolLines.push("import type { MethodName, WireType } from './contract'") +webmcpToolLines.push('') +webmcpToolLines.push('export type WebMCPToolRecord = {') +webmcpToolLines.push(' readonly wireType: WireType') +webmcpToolLines.push(' readonly name: string') +webmcpToolLines.push(' readonly description: string') +webmcpToolLines.push(' readonly inputSchema: {') +webmcpToolLines.push(" readonly type: 'object'") +webmcpToolLines.push(' readonly properties?: Readonly>') +webmcpToolLines.push(' readonly required?: readonly string[]') +webmcpToolLines.push(' }') +webmcpToolLines.push(' readonly annotations: {') for (const hint of WEBMCP_ANNOTATION_KEYS) { - webmcpToolLines.push(` readonly ${hint}?: boolean`); + webmcpToolLines.push(` readonly ${hint}?: boolean`) } -webmcpToolLines.push(" }"); -webmcpToolLines.push("}"); -webmcpToolLines.push(""); -webmcpToolLines.push("export const WEBMCP_TOOLS = {"); +webmcpToolLines.push(' }') +webmcpToolLines.push('}') +webmcpToolLines.push('') +webmcpToolLines.push('export const WEBMCP_TOOLS = {') for (const op of contract.operations) { - webmcpToolLines.push( - ` ${toCamel(op.request_type)}: ${JSON.stringify(webmcpToolRecord(op))},` - ); + webmcpToolLines.push(` ${toCamel(op.request_type)}: ${JSON.stringify(webmcpToolRecord(op))},`) } -webmcpToolLines.push( - "} as const satisfies Record" -); +webmcpToolLines.push('} as const satisfies Record') -writeFileSync( - join(GENERATED_DIR, "webmcp-tools.ts"), - renderFile(webmcpToolLines) -); +writeFileSync(join(GENERATED_DIR, 'webmcp-tools.ts'), renderFile(webmcpToolLines)) // --- drift.ts (compile-time drift guards; type-checked, not bundled) -------- // One exported tuple gathers every guard so noUnusedLocals stays happy while the // type-parameter constraints still fail the build the instant a representation // diverges. Imported by nothing; pure type-level (no runtime). -const driftLines = []; -driftLines.push( - "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." -); -driftLines.push("import type { EditorEvent, IframeActions } from '../types'"); -driftLines.push("import type * as Schemas from './schemas'"); -driftLines.push("import type * as Contract from './contract'"); -driftLines.push(""); -driftLines.push( - "type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false" -); -driftLines.push("type AssertTrue = T"); -driftLines.push(""); -driftLines.push( - "// IframeActions method set must exactly equal the generated operation methods," -); -driftLines.push( - "// each zod schema must stay mutually assignable to its plain contract type, and" -); -driftLines.push( - "// the hand-maintained EditorEvent union must exactly match the generated outbound events" -); -driftLines.push( - "// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one)." -); -driftLines.push("export type DriftGuards = ["); -driftLines.push( - " AssertTrue>," -); -driftLines.push( - ' AssertTrue>,' -); -driftLines.push( - " AssertTrue>," -); +const driftLines = [] +driftLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +driftLines.push("import type { EditorEvent, IframeActions } from '../types'") +driftLines.push("import type * as Schemas from './schemas'") +driftLines.push("import type * as Contract from './contract'") +driftLines.push('') +driftLines.push('type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false') +driftLines.push('type AssertTrue = T') +driftLines.push('') +driftLines.push('// IframeActions method set must exactly equal the generated operation methods,') +driftLines.push('// each zod schema must stay mutually assignable to its plain contract type, and') +driftLines.push('// the hand-maintained EditorEvent union must exactly match the generated outbound events') +driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one).") +driftLines.push('export type DriftGuards = [') +driftLines.push(" AssertTrue>,") +driftLines.push(' AssertTrue>,') +driftLines.push(" AssertTrue>,") for (const op of contract.operations) { - const stem = toPascal(op.request_type); - driftLines.push( - ` AssertTrue>,` - ); + const stem = toPascal(op.request_type) + driftLines.push(` AssertTrue>,`) } -driftLines.push("]"); -driftLines.push(""); +driftLines.push(']') +driftLines.push('') -writeFileSync(join(GENERATED_DIR, "drift.ts"), renderFile(driftLines)); +writeFileSync(join(GENERATED_DIR, 'drift.ts'), renderFile(driftLines)) // --- tools.ts (agentic tool registry: name -> { description, inputSchema }) -- const agenticOperations = contract.operations.filter( - (op) => !NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase()) -); -const toolLines = []; -toolLines.push( - "// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand." -); -toolLines.push("import * as Schemas from './schemas'"); -toolLines.push(""); -toolLines.push( - "// The agentic tool registry. Each tool name is the camelCase operation name;" -); -toolLines.push( - "// load_document is excluded here (a host/setup action; the WebMCP surface registers it)." -); -toolLines.push("export const TOOL_DEFINITIONS = {"); + (op) => !NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase()), +) +const toolLines = [] +toolLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +toolLines.push("import * as Schemas from './schemas'") +toolLines.push('') +toolLines.push('// The agentic tool registry. Each tool name is the camelCase operation name;') +toolLines.push('// load_document is excluded here (a host/setup action; the WebMCP surface registers it).') +toolLines.push('export const TOOL_DEFINITIONS = {') for (const op of agenticOperations) { - const stem = toPascal(op.request_type); + const stem = toPascal(op.request_type) toolLines.push( - ` ${toCamel(op.request_type)}: { description: ${JSON.stringify( - op.description - )}, inputSchema: Schemas.${stem}Input },` - ); + ` ${toCamel(op.request_type)}: { description: ${JSON.stringify(op.description)}, inputSchema: Schemas.${stem}Input },`, + ) } -toolLines.push("} as const"); -toolLines.push(""); -toolLines.push("export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS"); -toolLines.push(""); +toolLines.push('} as const') +toolLines.push('') +toolLines.push('export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS') +toolLines.push('') -writeFileSync(join(GENERATED_DIR, "tools.ts"), renderFile(toolLines)); +writeFileSync(join(GENERATED_DIR, 'tools.ts'), renderFile(toolLines)) console.log( `Generated contract.ts (${contract.operations.length} ops, ${contract.events.length} events, ` + - `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + method-names.ts + webmcp-tools.ts` -); + `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + method-names.ts + webmcp-tools.ts`, +) diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 863f6eb9..76e1e232 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -1,7 +1,7 @@ -import { fromWireData, toWireData } from "./case-transform"; -import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from "./logger"; -import { isBridgeResultLike } from "./result"; -import type { OutboundEventType, WireType } from "./generated/contract"; +import { fromWireData, toWireData } from './case-transform' +import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' +import { isBridgeResultLike } from './result' +import type { OutboundEventType, WireType } from './generated/contract' import type { BridgeResult, BridgeState, @@ -10,128 +10,103 @@ import type { IframeActions, PageFocusedPayload, SubmissionSentPayload, -} from "./types"; -import { - modelContextCandidates, - normalizeWebMCPOptions, - type WebMCPOptions, -} from "./webmcp-shared"; +} from './types' +import { modelContextCandidates, normalizeWebMCPOptions, type WebMCPOptions } from './webmcp-shared' export type AttachEmbedArgs = { // Getter returning the iframe element. Called each time the bridge needs to // reach the editor (postMessage send, probe, identity check on incoming // messages). Framework-agnostic: React callers pass `() => ref.current`, // vanilla callers pass `() => document.getElementById('my-iframe')`. - getIframe: () => HTMLIFrameElement | null; + getIframe: () => HTMLIFrameElement | null // Origin the iframe is served from. Every incoming message is verified against // this value, and it is the postMessage target origin. - editorOrigin: string; - logger?: BridgeLogger; + editorOrigin: string + logger?: BridgeLogger // Optional teardown hook invoked once on dispose() after the bridge has cleaned // up (createEmbed's create path uses it to remove the iframe it created). - onDispose?: () => void; + onDispose?: () => void // Expose the editor operations as WebMCP tools on the host page (see ./webmcp). - webMCP?: WebMCPOptions; + webMCP?: WebMCPOptions // Internal wiring for createEmbed's "load the document once ready" flow: called on // every lifecycle transition (booting -> editorReady -> documentLoaded), including // readiness reached via the liveness probe (which emits no editor event). NOT a // public event — consumers observe lifecycle via the EDITOR_READY / DOCUMENT_LOADED events. - onStateChange?: (state: BridgeState) => void; -}; + onStateChange?: (state: BridgeState) => void +} // One uniform request/response timeout. The editor is proven alive (EDITOR_READY // + the readiness probe), so this is purely a dead-iframe safety net — deliberately // generous so a request posted behind a slow FIFO op (auto-detection / OCR) is not // starved by a per-op budget that starts at enqueue, not at dispatch. Dependent // calls should be awaited rather than fired concurrently. -const REQUEST_TIMEOUT_MS = 60_000; -const EDITOR_READY_PROBE_INTERVAL_MS = 500; -const EDITOR_READY_HARD_FALLBACK_MS = 30_000; +const REQUEST_TIMEOUT_MS = 60_000 +const EDITOR_READY_PROBE_INTERVAL_MS = 500 +const EDITOR_READY_HARD_FALLBACK_MS = 30_000 // Compile-time drift guards: the outbound-event literals the bridge matches must // remain members of the generated vocabulary, or `tsc` fails (an editor rename // would otherwise silently stop the bridge emitting that event). Type-only, so // no generated value (the OPERATIONS table) is pulled into the zero-dep root. -const EDITOR_READY_EVENT: Extract = - "EDITOR_READY"; -const DOCUMENT_LOADED_EVENT: Extract = - "DOCUMENT_LOADED"; -const SUBMISSION_SENT_EVENT: Extract = - "SUBMISSION_SENT"; -const PAGE_FOCUSED_EVENT: Extract = - "PAGE_FOCUSED"; +const EDITOR_READY_EVENT: Extract = 'EDITOR_READY' +const DOCUMENT_LOADED_EVENT: Extract = 'DOCUMENT_LOADED' +const SUBMISSION_SENT_EVENT: Extract = 'SUBMISSION_SENT' +const PAGE_FOCUSED_EVENT: Extract = 'PAGE_FOCUSED' // The reply envelope (manifest `protocol`), not an event: never part of OutboundEventType. -const REQUEST_RESULT_TYPE = "REQUEST_RESULT"; +const REQUEST_RESULT_TYPE = 'REQUEST_RESULT' const generateRequestId = (): string => { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() } - return `req_${Math.random().toString(36).slice(2)}_${Date.now().toString( - 36 - )}`; -}; + return `req_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}` +} // The single deliberate trust boundary. The editor is contracted to reply with // `data` matching the operation's output_schema; that shape cannot be re-derived // at runtime (the zero-dep root carries no validator), so this generic relabels // the validated envelope. Errors are TData-independent and pass through untouched. -const relabelResult = ( - result: BridgeResult -): BridgeResult => { +const relabelResult = (result: BridgeResult): BridgeResult => { if (result.success) { - return { success: true, data: result.data as TData }; + return { success: true, data: result.data as TData } } - return result; -}; + return result +} // Op results reach SDK callers camelCased; the WebMCP tools hand the agent the wire // shape (snake_case), the one the manifest's tool descriptions promise. -type ResultShape = "sdk" | "wire"; +type ResultShape = 'sdk' | 'wire' type PendingRequest = { - resolve: (result: BridgeResult) => void; - resultShape: ResultShape; - wireType: WireType; - startedAtMs: number; - timeoutId: ReturnType; -}; + resolve: (result: BridgeResult) => void + resultShape: ResultShape + wireType: WireType + startedAtMs: number + timeoutId: ReturnType +} // Validates + returns the editor's outbound event payload VERBATIM (snake_case): // these are forwarded to onEmbedEvent unchanged, so they keep the wire shape (the // established EmbedEvent contract). Op method payloads, by contrast, are camelCased. -const asSubmissionSent = ( - data: Record | undefined -): SubmissionSentPayload | null => { - const documentId = data?.document_id; - const submissionId = data?.submission_id; - if (typeof documentId === "string" && typeof submissionId === "string") { - return { document_id: documentId, submission_id: submissionId }; +const asSubmissionSent = (data: Record | undefined): SubmissionSentPayload | null => { + const documentId = data?.document_id + const submissionId = data?.submission_id + if (typeof documentId === 'string' && typeof submissionId === 'string') { + return { document_id: documentId, submission_id: submissionId } } - return null; -}; - -const asPageFocused = ( - data: Record | undefined -): PageFocusedPayload | null => { - const previousPage = data?.previous_page; - const currentPage = data?.current_page; - const totalPages = data?.total_pages; - const previousIsValid = - previousPage === null || typeof previousPage === "number"; - if ( - previousIsValid && - typeof currentPage === "number" && - typeof totalPages === "number" - ) { - return { - previous_page: previousPage, - current_page: currentPage, - total_pages: totalPages, - }; + return null +} + +const asPageFocused = (data: Record | undefined): PageFocusedPayload | null => { + const previousPage = data?.previous_page + const currentPage = data?.current_page + const totalPages = data?.total_pages + const previousIsValid = previousPage === null || typeof previousPage === 'number' + if (previousIsValid && typeof currentPage === 'number' && typeof totalPages === 'number') { + return { previous_page: previousPage, current_page: currentPage, total_pages: totalPages } } - return null; -}; + return null +} export const attachEmbed = ({ getIframe, @@ -141,11 +116,11 @@ export const attachEmbed = ({ onStateChange, webMCP, }: AttachEmbedArgs): Embed => { - const logger = makeSafeLogger(providedLogger); - const pending = new Map(); - let state: BridgeState = { kind: "booting" }; - let documentId: string | null = null; - let disposed = false; + const logger = makeSafeLogger(providedLogger) + const pending = new Map() + let state: BridgeState = { kind: 'booting' } + let documentId: string | null = null + let disposed = false // Per-type channels for the editor's outbound events, forwarded verbatim (the // established EmbedEvent contract). A throwing listener can't stop the others or break cleanup. @@ -153,151 +128,128 @@ export const attachEmbed = ({ // load-when-ready gate), not an event: consumers observe readiness via the // EDITOR_READY / DOCUMENT_LOADED events. type Channel = { - subscribe: (listener: (payload: T) => void) => () => void; - emit: (payload: T) => void; - clear: () => void; - }; + subscribe: (listener: (payload: T) => void) => () => void + emit: (payload: T) => void + clear: () => void + } const reportListenerError = (error: unknown): void => - logger.error("iframe.listener_threw", { - message: error instanceof Error ? error.message : String(error), - }); + logger.error('iframe.listener_threw', { message: error instanceof Error ? error.message : String(error) }) const makeChannel = (): Channel => { - const listeners = new Set<(payload: T) => void>(); + const listeners = new Set<(payload: T) => void>() return { subscribe: (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); + listeners.add(listener) + return () => listeners.delete(listener) }, emit: (payload) => { for (const listener of listeners) { try { - listener(payload); + listener(payload) } catch (error) { - reportListenerError(error); + reportListenerError(error) } } }, clear: () => listeners.clear(), - }; - }; + } + } // One channel per editor event type, so on(type, handler) hands the handler that // event's typed payload directly — no cast, no union narrowing. - const channels: { - [TEventType in keyof EditorEventMap]: Channel; - } = { + const channels: { [TEventType in keyof EditorEventMap]: Channel } = { EDITOR_READY: makeChannel(), DOCUMENT_LOADED: makeChannel(), PAGE_FOCUSED: makeChannel(), SUBMISSION_SENT: makeChannel(), - }; + } // Granular, EventEmitter-style subscription. Returns an unsubscribe fn. const on = ( type: TEventType, - handler: (data: EditorEventMap[TEventType]) => void - ): (() => void) => channels[type].subscribe(handler); + handler: (data: EditorEventMap[TEventType]) => void, + ): (() => void) => channels[type].subscribe(handler) // WebMCP tools are registered once the editor is alive (an agent enumerating tools // at page load must not post into an iframe that has no listener yet), only when the // embedder opted in and the page exposes a model context: the module and the schema // table it reads load for no one else. Aborting the signal on dispose unregisters // every tool. - const webMCPController = new AbortController(); - const webMCPOptions = normalizeWebMCPOptions(webMCP); + const webMCPController = new AbortController() + const webMCPOptions = normalizeWebMCPOptions(webMCP) // Latched while a registration attempt is in flight or succeeded; released when the // module finds no usable context or fails to load, so the next non-booting transition // probes again and a runtime that installs its context after a fast EDITOR_READY (or a // transient chunk fetch failure) still gets the tools. A transition during the load // itself needs no replay: the module probes on arrival. - let webMCPStarted = false; + let webMCPStarted = false const startWebMCP = (): void => { if (!webMCPOptions.enabled || webMCPStarted) { - return; + return } if (modelContextCandidates().length === 0) { - logger.info("webmcp.unavailable", { reason: "no_model_context" }); - return; + logger.info('webmcp.unavailable', { reason: 'no_model_context' }) + return } - webMCPStarted = true; - void import("./webmcp") + webMCPStarted = true + void import('./webmcp') .then(({ registerWebMCPTools }) => { const registered = registerWebMCPTools({ - dispatch: (wireType, data) => postRequest(wireType, data, "wire"), + dispatch: (wireType, data) => postRequest(wireType, data, 'wire'), exclude: webMCPOptions.exclude, signal: webMCPController.signal, logger, - }); + }) if (!registered) { - webMCPStarted = false; + webMCPStarted = false } }) .catch((error: unknown) => { - webMCPStarted = false; - logger.error("webmcp.load_failed", { - message: error instanceof Error ? error.message : String(error), - }); - }); - }; + webMCPStarted = false + logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) + }) + } const transitionTo = (next: BridgeState): void => { - state = next; - onStateChange?.(next); - if (next.kind !== "booting") { - startWebMCP(); + state = next + onStateChange?.(next) + if (next.kind !== 'booting') { + startWebMCP() } - }; + } - const postRequest = ( - wireType: WireType, - data: unknown, - resultShape: ResultShape - ): Promise> => + const postRequest = (wireType: WireType, data: unknown, resultShape: ResultShape): Promise> => new Promise>((resolve) => { if (disposed) { resolve({ success: false, - error: { - code: "unexpected:bridge_disposed", - message: "Editor bridge was disposed", - }, - }); - return; + error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, + }) + return } - const iframe = getIframe(); + const iframe = getIframe() if (iframe === null || iframe.contentWindow === null) { resolve({ success: false, - error: { - code: "unexpected:iframe_not_mounted", - message: "Editor iframe is not mounted", - }, - }); - return; + error: { code: 'unexpected:iframe_not_mounted', message: 'Editor iframe is not mounted' }, + }) + return } - const requestId = generateRequestId(); - const startedAtMs = Date.now(); + const requestId = generateRequestId() + const startedAtMs = Date.now() // Log identifiers + timing only — never the payload body (it can carry the // document data URL, field values, signatures, or other PII). - logger.info("iframe.request_sent", { - request_id: requestId, - type: wireType, - timeout_ms: REQUEST_TIMEOUT_MS, - }); + logger.info('iframe.request_sent', { request_id: requestId, type: wireType, timeout_ms: REQUEST_TIMEOUT_MS }) const timeoutId = setTimeout(() => { - pending.delete(requestId); - logger.error("iframe.request_timed_out", { + pending.delete(requestId) + logger.error('iframe.request_timed_out', { request_id: requestId, type: wireType, elapsed_ms: Date.now() - startedAtMs, - }); + }) resolve({ success: false, - error: { - code: "unexpected:timeout", - message: `Editor request '${wireType}' timed out after ${REQUEST_TIMEOUT_MS}ms`, - }, - }); - }, REQUEST_TIMEOUT_MS); + error: { code: 'unexpected:timeout', message: `Editor request '${wireType}' timed out after ${REQUEST_TIMEOUT_MS}ms` }, + }) + }, REQUEST_TIMEOUT_MS) pending.set(requestId, { resolve: (result) => resolve(relabelResult(result)), @@ -305,113 +257,101 @@ export const attachEmbed = ({ wireType, startedAtMs, timeoutId, - }); + }) // Serializing (circular / BigInt input) or posting (dead contentWindow) can // throw; convert that into a Result rather than rejecting the promise. try { - const outbound = JSON.stringify({ - type: wireType, - request_id: requestId, - data: toWireData(data), - }); - iframe.contentWindow.postMessage(outbound, editorOrigin); - logger.debug("iframe.request_posted", { - request_id: requestId, - type: wireType, - char_count: outbound.length, - }); + const outbound = JSON.stringify({ type: wireType, request_id: requestId, data: toWireData(data) }) + iframe.contentWindow.postMessage(outbound, editorOrigin) + logger.debug('iframe.request_posted', { request_id: requestId, type: wireType, char_count: outbound.length }) } catch (error) { - clearTimeout(timeoutId); - pending.delete(requestId); + clearTimeout(timeoutId) + pending.delete(requestId) resolve({ success: false, error: { - code: "unexpected:unknown", - message: `Failed to post '${wireType}': ${ - error instanceof Error ? error.message : String(error) - }`, + code: 'unexpected:unknown', + message: `Failed to post '${wireType}': ${error instanceof Error ? error.message : String(error)}`, }, - }); + }) } - }); + }) - const sendRequest = ( - wireType: WireType, - data: unknown - ): Promise> => postRequest(wireType, data, "sdk"); + const sendRequest = (wireType: WireType, data: unknown): Promise> => + postRequest(wireType, data, 'sdk') // --- Editor-readiness probing --------------------------------------------- // Fire a GET_FIELDS every 500ms until the editor confirms a document is loaded // (success: true) or the hard fallback fires. A success: false with // bad_request:no_document_loaded still proves the postMessage bridge is alive, // so we flip to editor_ready on the first response and keep probing for loaded. - const probeRequestIds = new Set(); - let probeInterval: ReturnType | null = null; + const probeRequestIds = new Set() + let probeInterval: ReturnType | null = null const stopProbing = (): void => { if (probeInterval !== null) { - clearInterval(probeInterval); - probeInterval = null; + clearInterval(probeInterval) + probeInterval = null } - probeRequestIds.clear(); - }; + probeRequestIds.clear() + } // `editor_ready_event` = a real EDITOR_READY message (iframe re-mounting for a // fresh document cycle). `probe`/`fallback` only prove liveness and must NEVER // drop a loaded document back to editorReady. - type EditorReadySource = "editor_ready_event" | "probe" | "fallback"; + type EditorReadySource = 'editor_ready_event' | 'probe' | 'fallback' const markEditorReady = (source: EditorReadySource): void => { switch (state.kind) { - case "documentLoaded": - if (source === "editor_ready_event") { - transitionTo({ kind: "editorReady" }); + case 'documentLoaded': + if (source === 'editor_ready_event') { + transitionTo({ kind: 'editorReady' }) } - return; - case "booting": - transitionTo({ kind: "editorReady" }); - return; - case "editorReady": - return; + return + case 'booting': + transitionTo({ kind: 'editorReady' }) + return + case 'editorReady': + return default: - state satisfies never; + state satisfies never } - }; + } - let readyTimeout: ReturnType | null = null; + let readyTimeout: ReturnType | null = null const clearReadyTimeout = (): void => { if (readyTimeout !== null) { - clearTimeout(readyTimeout); - readyTimeout = null; + clearTimeout(readyTimeout) + readyTimeout = null } - }; + } const markDocumentLoaded = (): void => { - if (state.kind === "documentLoaded") { - return; + if (state.kind === 'documentLoaded') { + return } - clearReadyTimeout(); - stopProbing(); - transitionTo({ kind: "documentLoaded", documentId }); - }; + clearReadyTimeout() + stopProbing() + transitionTo({ kind: 'documentLoaded', documentId }) + } const sendProbe = (): void => { - const iframe = getIframe(); + const iframe = getIframe() if (iframe === null || iframe.contentWindow === null) { - return; + return } - const probeId = generateRequestId(); + const probeId = generateRequestId() try { iframe.contentWindow.postMessage( - JSON.stringify({ type: "GET_FIELDS", request_id: probeId, data: {} }), - editorOrigin - ); + JSON.stringify({ type: 'GET_FIELDS', request_id: probeId, data: {} }), + editorOrigin, + ) // Track only successfully-posted probes (a failed post leaves no id to match). - probeRequestIds.add(probeId); + probeRequestIds.add(probeId) } catch { // Best-effort liveness probe; a transient post failure just retries next tick. } - }; + } // Hard readiness fallback. It only WARNS if we are still booting (the editor // never confirmed readiness), and it stops the 500ms probe loop: by this point @@ -419,231 +359,209 @@ export const attachEmbed = ({ // we rely on the DOCUMENT_LOADED event rather than probing — and accumulating // probe ids — indefinitely. readyTimeout = setTimeout(() => { - readyTimeout = null; - if (state.kind === "booting") { - logger.warn("editor.ready_fallback_timeout", { - timeout_ms: EDITOR_READY_HARD_FALLBACK_MS, - }); + readyTimeout = null + if (state.kind === 'booting') { + logger.warn('editor.ready_fallback_timeout', { timeout_ms: EDITOR_READY_HARD_FALLBACK_MS }) } - markEditorReady("fallback"); - stopProbing(); - }, EDITOR_READY_HARD_FALLBACK_MS); + markEditorReady('fallback') + stopProbing() + }, EDITOR_READY_HARD_FALLBACK_MS) - sendProbe(); - probeInterval = setInterval(sendProbe, EDITOR_READY_PROBE_INTERVAL_MS); + sendProbe() + probeInterval = setInterval(sendProbe, EDITOR_READY_PROBE_INTERVAL_MS) const onMessage = (event: MessageEvent): void => { if (event.origin !== editorOrigin) { - return; + return } - const iframe = getIframe(); + const iframe = getIframe() // Iframe torn down (ref cleared between dispatch and receive, or a message // delivered after dispose): don't transition or resolve on a dead iframe. if (iframe === null || event.source !== iframe.contentWindow) { - return; + return } - const payload = ((): { - type?: string; - data?: Record; - request_id?: string; - } | null => { - if (typeof event.data !== "string") { - return null; + const payload = ((): { type?: string; data?: Record; request_id?: string } | null => { + if (typeof event.data !== 'string') { + return null } try { - const parsed: unknown = JSON.parse(event.data); - return typeof parsed === "object" && parsed !== null ? parsed : null; + const parsed: unknown = JSON.parse(event.data) + return typeof parsed === 'object' && parsed !== null ? parsed : null } catch { - return null; + return null } - })(); + })() if (payload === null) { - return; + return } // Log the message type + correlation id only — never the body (PII). - logger.debug("iframe.message_received", { - type: payload.type, - request_id: payload.request_id, - }); + logger.debug('iframe.message_received', { type: payload.type, request_id: payload.request_id }) if (payload.type === EDITOR_READY_EVENT) { - markEditorReady("editor_ready_event"); - channels.EDITOR_READY.emit({}); - return; + markEditorReady('editor_ready_event') + channels.EDITOR_READY.emit({}) + return } if (payload.type === DOCUMENT_LOADED_EVENT) { - const rawDocId = payload.data?.document_id; - if (typeof rawDocId === "string" && rawDocId !== "") { + const rawDocId = payload.data?.document_id + if (typeof rawDocId === 'string' && rawDocId !== '') { // Forward every real DOCUMENT_LOADED verbatim (snake wire data). - channels.DOCUMENT_LOADED.emit({ document_id: rawDocId }); + channels.DOCUMENT_LOADED.emit({ document_id: rawDocId }) if (documentId !== rawDocId) { - documentId = rawDocId; - if (state.kind === "documentLoaded") { + documentId = rawDocId + if (state.kind === 'documentLoaded') { // Late DOCUMENT_LOADED after the probe already flipped the state: still // refresh the internal documentId snapshot. - transitionTo({ kind: "documentLoaded", documentId }); - return; + transitionTo({ kind: 'documentLoaded', documentId }) + return } } } - markDocumentLoaded(); - return; + markDocumentLoaded() + return } if (payload.type === SUBMISSION_SENT_EVENT) { - const submission = asSubmissionSent(payload.data); + const submission = asSubmissionSent(payload.data) if (submission !== null) { - channels.SUBMISSION_SENT.emit(submission); + channels.SUBMISSION_SENT.emit(submission) } - return; + return } if (payload.type === PAGE_FOCUSED_EVENT) { - const pageFocused = asPageFocused(payload.data); + const pageFocused = asPageFocused(payload.data) if (pageFocused !== null) { - channels.PAGE_FOCUSED.emit(pageFocused); + channels.PAGE_FOCUSED.emit(pageFocused) } - return; + return } if (payload.type !== REQUEST_RESULT_TYPE) { - return; + return } - const requestId = - typeof payload.data?.request_id === "string" - ? payload.data.request_id - : null; + const requestId = typeof payload.data?.request_id === 'string' ? payload.data.request_id : null if (requestId === null) { - return; + return } if (probeRequestIds.has(requestId)) { - probeRequestIds.delete(requestId); - markEditorReady("probe"); - const probeResult = payload.data?.result; + probeRequestIds.delete(requestId) + markEditorReady('probe') + const probeResult = payload.data?.result if (isBridgeResultLike(probeResult) && probeResult.success === true) { - markDocumentLoaded(); + markDocumentLoaded() } - return; + return } - const entry = pending.get(requestId); + const entry = pending.get(requestId) if (entry === undefined) { // Not a tracked request: a late/stale reply, or a probe reply received after // the probe loop was torn down. Not an error. - logger.debug("iframe.request_unmatched", { request_id: requestId }); - return; + logger.debug('iframe.request_unmatched', { request_id: requestId }) + return } - pending.delete(requestId); - clearTimeout(entry.timeoutId); - const rawResult = payload.data?.result; + pending.delete(requestId) + clearTimeout(entry.timeoutId) + const rawResult = payload.data?.result const result: BridgeResult = ((): BridgeResult => { if (!isBridgeResultLike(rawResult)) { return { success: false, - error: { - code: "unexpected:malformed_result", - message: "REQUEST_RESULT payload had no valid result", - }, - }; + error: { code: 'unexpected:malformed_result', message: 'REQUEST_RESULT payload had no valid result' }, + } } // Editor void ops emit `{ success: true }` without `data`. Normalize to // `{ success: true, data: null }` so consumers never see the missing field. - if (rawResult.success === true && !("data" in rawResult)) { - return { success: true, data: null }; + if (rawResult.success === true && !('data' in rawResult)) { + return { success: true, data: null } } switch (entry.resultShape) { - case "wire": - return rawResult; - case "sdk": { + case 'wire': + return rawResult + case 'sdk': { // wire (snake_case) -> SDK (camelCase) for the data / error.details payloads. // The transform preserves the envelope shape, so re-narrowing with the same // guard re-types it without a cast. - const camelCased = fromWireData(rawResult); + const camelCased = fromWireData(rawResult) if (!isBridgeResultLike(camelCased)) { return { success: false, - error: { - code: "unexpected:malformed_result", - message: "REQUEST_RESULT payload had no valid result", - }, - }; + error: { code: 'unexpected:malformed_result', message: 'REQUEST_RESULT payload had no valid result' }, + } } - return camelCased; + return camelCased } default: - entry.resultShape satisfies never; - return rawResult; + entry.resultShape satisfies never + return rawResult } - })(); - logger.info("iframe.request_received", { + })() + logger.info('iframe.request_received', { request_id: requestId, type: entry.wireType, elapsed_ms: Date.now() - entry.startedAtMs, success: result.success, - }); - entry.resolve(result); - }; + }) + entry.resolve(result) + } - window.addEventListener("message", onMessage); + window.addEventListener('message', onMessage) // The bridge is a thin interface, not a policy engine: the editor owns FIFO // ordering, input validation, and typed errors, and always replies. Each method // posts the request and correlates the reply; no client-side validation. const methods = { - batchSetFieldValue: (input) => sendRequest("BATCH_SET_FIELD_VALUE", input), - createField: (input) => sendRequest("CREATE_FIELD", input), + batchSetFieldValue: (input) => sendRequest('BATCH_SET_FIELD_VALUE', input), + createField: (input) => sendRequest('CREATE_FIELD', input), // All-optional input: deleteFields() with no args clears every field. - deleteFields: (input) => sendRequest("DELETE_FIELDS", input ?? {}), - deletePages: (input) => sendRequest("DELETE_PAGES", input), - detectFields: () => sendRequest("DETECT_FIELDS", {}), - download: () => sendRequest("DOWNLOAD", {}), - focusField: (input) => sendRequest("FOCUS_FIELD", input), - getAnnotatedPage: (input) => sendRequest("GET_ANNOTATED_PAGE", input), - getDocumentContent: (input) => - sendRequest("GET_DOCUMENT_CONTENT", input ?? {}), - getFields: () => sendRequest("GET_FIELDS", {}), - goTo: (input) => sendRequest("GO_TO", input), - loadDocument: (input) => sendRequest("LOAD_DOCUMENT", input), - movePage: (input) => sendRequest("MOVE_PAGE", input), - rotatePage: (input) => sendRequest("ROTATE_PAGE", input), - selectTool: (input) => sendRequest("SELECT_TOOL", input), - setFieldValue: (input) => sendRequest("SET_FIELD_VALUE", input), - submit: (input) => sendRequest("SUBMIT", input), - } satisfies IframeActions; + deleteFields: (input) => sendRequest('DELETE_FIELDS', input ?? {}), + deletePages: (input) => sendRequest('DELETE_PAGES', input), + detectFields: () => sendRequest('DETECT_FIELDS', {}), + download: () => sendRequest('DOWNLOAD', {}), + focusField: (input) => sendRequest('FOCUS_FIELD', input), + getAnnotatedPage: (input) => sendRequest('GET_ANNOTATED_PAGE', input), + getDocumentContent: (input) => sendRequest('GET_DOCUMENT_CONTENT', input ?? {}), + getFields: () => sendRequest('GET_FIELDS', {}), + goTo: (input) => sendRequest('GO_TO', input), + loadDocument: (input) => sendRequest('LOAD_DOCUMENT', input), + movePage: (input) => sendRequest('MOVE_PAGE', input), + rotatePage: (input) => sendRequest('ROTATE_PAGE', input), + selectTool: (input) => sendRequest('SELECT_TOOL', input), + setFieldValue: (input) => sendRequest('SET_FIELD_VALUE', input), + submit: (input) => sendRequest('SUBMIT', input), + } satisfies IframeActions const dispose = (): void => { if (disposed) { - return; + return } - disposed = true; - webMCPController.abort(); - window.removeEventListener("message", onMessage); - clearReadyTimeout(); - stopProbing(); + disposed = true + webMCPController.abort() + window.removeEventListener('message', onMessage) + clearReadyTimeout() + stopProbing() for (const { timeoutId, resolve } of pending.values()) { - clearTimeout(timeoutId); + clearTimeout(timeoutId) resolve({ success: false, - error: { - code: "unexpected:bridge_disposed", - message: "Editor bridge was disposed", - }, - }); + error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, + }) } - pending.clear(); + pending.clear() for (const channel of Object.values(channels)) { - channel.clear(); + channel.clear() } - onDispose?.(); - }; + onDispose?.() + } const embed: Embed = { actions: methods, events: { on }, lifecycle: { dispose }, - }; - return embed; -}; + } + return embed +} diff --git a/embed/src/generated/schemas.ts b/embed/src/generated/schemas.ts index 6572f652..c62c2fc9 100644 --- a/embed/src/generated/schemas.ts +++ b/embed/src/generated/schemas.ts @@ -6,7 +6,7 @@ export const BatchSetFieldValueInput = z.object({ fieldId: z.string().describe("ID of the field to update."), value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), })).describe("The fields to set, at least one, each field at most once. Fields not listed keep their value."), -}).describe("Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.") +}) export type BatchSetFieldValueInput = z.infer export const CreateFieldInput = z.object({ type: z.enum(["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]).describe("Field type to create."), @@ -16,65 +16,65 @@ export const CreateFieldInput = z.object({ height: z.number().describe("Field height, in PDF points."), page: z.number().int().describe("1-based page to place the field on."), value: z.string().describe("Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.").optional(), -}).describe("Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.") +}) export type CreateFieldInput = z.infer export const DeleteFieldsInput = z.object({ fieldIds: z.array(z.string()).describe("IDs of the fields to delete. Omit to delete every field on the target page.").optional(), page: z.number().int().describe("1-based page to scope the deletion to. Omit to target all pages.").optional(), -}).describe("Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.") +}) export type DeleteFieldsInput = z.infer export const DeletePagesInput = z.object({ pages: z.array(z.number().int()).describe("1-based page numbers to delete."), -}).describe("Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.") +}) export type DeletePagesInput = z.infer -export const DetectFieldsInput = z.object({}).describe("Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.") +export const DetectFieldsInput = z.object({}) export type DetectFieldsInput = z.infer -export const DownloadInput = z.object({}).describe("Generate and download the current document as a PDF. Returns no data.") +export const DownloadInput = z.object({}) export type DownloadInput = z.infer export const FocusFieldInput = z.object({ fieldId: z.string().describe("ID of the field to focus and scroll into view."), -}).describe("Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.") +}) export type FocusFieldInput = z.infer export const GetAnnotatedPageInput = z.object({ page: z.number().int().describe("1-based page to render, at its current position."), -}).describe("Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.") +}) export type GetAnnotatedPageInput = z.infer export const GetDocumentContentInput = z.object({ extractionMode: z.enum(["auto", "ocr"]).describe("Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.").optional(), -}).describe("Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.") +}) export type GetDocumentContentInput = z.infer -export const GetFieldsInput = z.object({}).describe("List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.") +export const GetFieldsInput = z.object({}) export type GetFieldsInput = z.infer export const GoToInput = z.object({ page: z.number().int().describe("1-based page to navigate to."), -}).describe("Scroll the editor to a specific 1-based page. Returns no data.") +}) export type GoToInput = z.infer export const LoadDocumentInput = z.object({ dataUrl: z.string().describe("The document to load: a data URL, or an http(s) URL the editor fetches."), name: z.string().describe("Optional display name for the document.").optional(), page: z.number().int().describe("Optional 1-based page to open the document on.").optional(), -}).describe("Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Replies once the document and its fields are ready, so the next operation can act on it, and DOCUMENT_LOADED follows the reply; the URL already open is left as is and answers once it settles; a document that cannot be loaded or prepared answers bad_request:failed_to_load_document. Returns no data.") +}) export type LoadDocumentInput = z.infer export const MovePageInput = z.object({ fromPage: z.number().int().describe("1-based current position of the page to move."), toPage: z.number().int().describe("1-based destination position for the page."), -}).describe("Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.") +}) export type MovePageInput = z.infer export const RotatePageInput = z.object({ page: z.number().int().describe("1-based page to rotate 90 degrees clockwise."), -}).describe("Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.") +}) export type RotatePageInput = z.infer export const SelectToolInput = z.object({ tool: z.enum(["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]).nullable().describe("Tool to activate, or null to deselect."), -}).describe("Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.") +}) export type SelectToolInput = z.infer export const SetFieldValueInput = z.object({ animate: z.boolean().describe("Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.").optional(), fieldId: z.string().describe("ID of the field to update."), value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), -}).describe("Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.") +}) export type SetFieldValueInput = z.infer export const SubmitInput = z.object({ downloadCopy: z.boolean().describe("When true, the signer also receives a downloaded copy on submit."), -}).describe("Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.") +}) export type SubmitInput = z.infer diff --git a/embed/src/tools.ts b/embed/src/tools.ts index c50088f0..19c7be30 100644 --- a/embed/src/tools.ts +++ b/embed/src/tools.ts @@ -4,8 +4,8 @@ // system prompt, no middleware (host-owned). zod is a peer dependency (used to // validate agentic input before dispatch; the thin root bridge does not validate). -import type { z } from "zod"; -import { TOOL_DEFINITIONS, type SimplePDFToolName } from "./generated/tools"; +import type { z } from 'zod' +import { TOOL_DEFINITIONS, type SimplePDFToolName } from './generated/tools' import { BatchSetFieldValueInput, CreateFieldInput, @@ -23,38 +23,31 @@ import { SelectToolInput, SetFieldValueInput, SubmitInput, -} from "./generated/schemas"; -import type { BridgeResult, IframeActions } from "./types"; +} from './generated/schemas' +import type { BridgeResult, IframeActions } from './types' -export { TOOL_DEFINITIONS as SIMPLEPDF_TOOLS } from "./generated/tools"; -export type { SimplePDFToolName } from "./generated/tools"; +export { TOOL_DEFINITIONS as SIMPLEPDF_TOOLS } from './generated/tools' +export type { SimplePDFToolName } from './generated/tools' -const AGENTIC_TOOL_NAMES: ReadonlySet = new Set( - Object.keys(TOOL_DEFINITIONS) -); +const AGENTIC_TOOL_NAMES: ReadonlySet = new Set(Object.keys(TOOL_DEFINITIONS)) -export const isSimplePDFToolName = ( - value: unknown -): value is SimplePDFToolName => - typeof value === "string" && AGENTIC_TOOL_NAMES.has(value); +export const isSimplePDFToolName = (value: unknown): value is SimplePDFToolName => + typeof value === 'string' && AGENTIC_TOOL_NAMES.has(value) const dispatch = ( schema: TSchema, input: unknown, - call: (value: z.infer) => Promise> + call: (value: z.infer) => Promise>, ): Promise> => { - const parsed = schema.safeParse(input); + const parsed = schema.safeParse(input) if (!parsed.success) { return Promise.resolve({ success: false, - error: { - code: "bad_request:invalid_input", - message: parsed.error.message, - }, - }); + error: { code: 'bad_request:invalid_input', message: parsed.error.message }, + }) } - return call(parsed.data); -}; + return call(parsed.data) +} // Validate the agentic input against the tool schema, then call the matching // editor action. The switch is exhaustive over SimplePDFToolName: a new agentic @@ -63,76 +56,51 @@ const dispatch = ( export const routeToolCall = ( actions: IframeActions, toolName: SimplePDFToolName, - input: unknown + input: unknown, ): Promise> => { switch (toolName) { - case "createField": - return dispatch(CreateFieldInput, input, (value) => - actions.createField(value) - ); - case "deleteFields": + case 'batchSetFieldValue': + return dispatch(BatchSetFieldValueInput, input, (value) => actions.batchSetFieldValue(value)) + case 'createField': + return dispatch(CreateFieldInput, input, (value) => actions.createField(value)) + case 'deleteFields': // All-optional: coerce a missing payload to {} (delete every field) like the // other no-required-input tools, so the model can omit it. - return dispatch(DeleteFieldsInput, input ?? {}, (value) => - actions.deleteFields(value) - ); - case "deletePages": - return dispatch(DeletePagesInput, input, (value) => - actions.deletePages(value) - ); + return dispatch(DeleteFieldsInput, input ?? {}, (value) => actions.deleteFields(value)) + case 'deletePages': + return dispatch(DeletePagesInput, input, (value) => actions.deletePages(value)) // No-input + all-optional tools coerce a missing input to {} (the AI SDK // sends {} for them); required-input tools below keep `input` so a missing // payload fails validation. - case "detectFields": - return dispatch(DetectFieldsInput, input ?? {}, () => - actions.detectFields() - ); - case "download": - return dispatch(DownloadInput, input ?? {}, () => actions.download()); - case "focusField": - return dispatch(FocusFieldInput, input, (value) => - actions.focusField(value) - ); - case "getAnnotatedPage": - return dispatch(GetAnnotatedPageInput, input, (value) => - actions.getAnnotatedPage(value) - ); - case "getDocumentContent": - return dispatch(GetDocumentContentInput, input ?? {}, (value) => - actions.getDocumentContent(value) - ); - case "getFields": - return dispatch(GetFieldsInput, input ?? {}, () => actions.getFields()); - case "goTo": - return dispatch(GoToInput, input, (value) => actions.goTo(value)); - case "movePage": - return dispatch(MovePageInput, input, (value) => actions.movePage(value)); - case "rotatePage": - return dispatch(RotatePageInput, input, (value) => - actions.rotatePage(value) - ); - case "selectTool": - return dispatch(SelectToolInput, input, (value) => - actions.selectTool(value) - ); - case "setFieldValue": - return dispatch(SetFieldValueInput, input, (value) => - actions.setFieldValue(value) - ); - case "batchSetFieldValue": - return dispatch(BatchSetFieldValueInput, input, (value) => - actions.batchSetFieldValue(value) - ); - case "submit": - return dispatch(SubmitInput, input, (value) => actions.submit(value)); + case 'detectFields': + return dispatch(DetectFieldsInput, input ?? {}, () => actions.detectFields()) + case 'download': + return dispatch(DownloadInput, input ?? {}, () => actions.download()) + case 'focusField': + return dispatch(FocusFieldInput, input, (value) => actions.focusField(value)) + case 'getAnnotatedPage': + return dispatch(GetAnnotatedPageInput, input, (value) => actions.getAnnotatedPage(value)) + case 'getDocumentContent': + return dispatch(GetDocumentContentInput, input ?? {}, (value) => actions.getDocumentContent(value)) + case 'getFields': + return dispatch(GetFieldsInput, input ?? {}, () => actions.getFields()) + case 'goTo': + return dispatch(GoToInput, input, (value) => actions.goTo(value)) + case 'movePage': + return dispatch(MovePageInput, input, (value) => actions.movePage(value)) + case 'rotatePage': + return dispatch(RotatePageInput, input, (value) => actions.rotatePage(value)) + case 'selectTool': + return dispatch(SelectToolInput, input, (value) => actions.selectTool(value)) + case 'setFieldValue': + return dispatch(SetFieldValueInput, input, (value) => actions.setFieldValue(value)) + case 'submit': + return dispatch(SubmitInput, input, (value) => actions.submit(value)) default: - toolName satisfies never; + toolName satisfies never return Promise.resolve({ success: false, - error: { - code: "bad_request:invalid_input", - message: `Unknown tool: ${String(toolName)}`, - }, - }); + error: { code: 'bad_request:invalid_input', message: `Unknown tool: ${String(toolName)}` }, + }) } -}; +} diff --git a/embed/src/types.ts b/embed/src/types.ts index 126b66e1..3508a6d5 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -31,7 +31,7 @@ import type { SetFieldValueInput, SubmissionSentPayload, SubmitInput, -} from "./generated/contract"; +} from './generated/contract' export type { BatchSetFieldValueInput, @@ -69,11 +69,11 @@ export type { SetFieldValueInput, SubmissionSentPayload, SubmitInput, -} from "./generated/contract"; +} from './generated/contract' // A single field as reported by get_fields. Alias kept for ergonomics; the // shape is owned by the generated GetFieldsOutput. -export type FieldRecord = GetFieldsOutput["fields"][number]; +export type FieldRecord = GetFieldsOutput['fields'][number] // --- Error model ----------------------------------------------------------- @@ -82,35 +82,31 @@ export type FieldRecord = GetFieldsOutput["fields"][number]; // `bad_request:invalid_input` is emitted by the /tools router when an agentic call // fails schema validation before dispatch (the thin root bridge does not validate). export type BridgeOwnedErrorCode = - | "bad_request:invalid_input" - | "unexpected:timeout" - | "unexpected:iframe_not_mounted" - | "unexpected:bridge_disposed" - | "unexpected:malformed_result" - | "unexpected:unknown"; + | 'bad_request:invalid_input' + | 'unexpected:timeout' + | 'unexpected:iframe_not_mounted' + | 'unexpected:bridge_disposed' + | 'unexpected:malformed_result' + | 'unexpected:unknown' // The complete, closed error union: package-owned transport codes UNION the // generated editor codes (the customer-facing set already redacted at the wire // boundary, where internal failures collapse to unexpected:internal_error). -export type BridgeErrorCode = BridgeOwnedErrorCode | EditorErrorCode; +export type BridgeErrorCode = BridgeOwnedErrorCode | EditorErrorCode // The codes of EditorErrorDetailsByCode carry typed details; every other code is a // plain { code, message }. Discriminated on `code` (distributed per detailed code). -type DetailedBridgeError = - TCode extends keyof EditorErrorDetailsByCode - ? { code: TCode; message: string; details: EditorErrorDetailsByCode[TCode] } - : never; +type DetailedBridgeError = TCode extends keyof EditorErrorDetailsByCode + ? { code: TCode; message: string; details: EditorErrorDetailsByCode[TCode] } + : never export type BridgeError = | DetailedBridgeError - | { - code: Exclude; - message: string; - }; + | { code: Exclude; message: string } export type BridgeResult = | { success: true; data: TData } - | { success: false; error: BridgeError }; + | { success: false; error: BridgeError } // --- Lifecycle ------------------------------------------------------------- @@ -119,23 +115,21 @@ export type BridgeResult = // (readiness probing + createEmbed's load-when-ready gate); not on the public handle — // readiness is observable via the EDITOR_READY / DOCUMENT_LOADED editor events. export type BridgeState = - | { kind: "booting" } - | { kind: "editorReady" } - | { kind: "documentLoaded"; documentId: string | null }; + | { kind: 'booting' } + | { kind: 'editorReady' } + | { kind: 'documentLoaded'; documentId: string | null } // The editor's outbound events, forwarded to onEmbedEvent VERBATIM: SCREAMING_SNAKE // `type` + snake_case `data` (the stable, established contract — deliberately NOT // camelCased, unlike op payloads). Every payload comes from the manifest `events`. export type EditorEvent = - | { type: "EDITOR_READY"; data: EditorReadyPayload } - | { type: "DOCUMENT_LOADED"; data: DocumentLoadedPayload } - | { type: "PAGE_FOCUSED"; data: PageFocusedPayload } - | { type: "SUBMISSION_SENT"; data: SubmissionSentPayload }; + | { type: 'EDITOR_READY'; data: EditorReadyPayload } + | { type: 'DOCUMENT_LOADED'; data: DocumentLoadedPayload } + | { type: 'PAGE_FOCUSED'; data: PageFocusedPayload } + | { type: 'SUBMISSION_SENT'; data: SubmissionSentPayload } // Event type -> payload, derived from EditorEvent, for the granular on(type, handler). -export type EditorEventMap = { - [TEvent in EditorEvent as TEvent["type"]]: TEvent["data"]; -}; +export type EditorEventMap = { [TEvent in EditorEvent as TEvent['type']]: TEvent['data'] } // --- Handle groups --------------------------------------------------------- @@ -145,34 +139,24 @@ export type EditorEventMap = { // throws. Drift-guarded against the generated operation set in ./generated/drift.ts // (a new operation fails the build until added here). export type IframeActions = { - batchSetFieldValue: (input: BatchSetFieldValueInput) => Promise; - createField: ( - input: CreateFieldInput - ) => Promise>; - deleteFields: ( - input?: DeleteFieldsInput - ) => Promise>; - deletePages: (input: DeletePagesInput) => Promise; - detectFields: () => Promise>; - download: () => Promise; - focusField: ( - input: FocusFieldInput - ) => Promise>; - getAnnotatedPage: ( - input: GetAnnotatedPageInput - ) => Promise>; - getDocumentContent: ( - input?: GetDocumentContentInput - ) => Promise>; - getFields: () => Promise>; - goTo: (input: GoToInput) => Promise; - loadDocument: (input: LoadDocumentInput) => Promise; - movePage: (input: MovePageInput) => Promise; - rotatePage: (input: RotatePageInput) => Promise; - selectTool: (input: SelectToolInput) => Promise; - setFieldValue: (input: SetFieldValueInput) => Promise; - submit: (input: SubmitInput) => Promise; -}; + batchSetFieldValue: (input: BatchSetFieldValueInput) => Promise + createField: (input: CreateFieldInput) => Promise> + deleteFields: (input?: DeleteFieldsInput) => Promise> + deletePages: (input: DeletePagesInput) => Promise + detectFields: () => Promise> + download: () => Promise + focusField: (input: FocusFieldInput) => Promise> + getAnnotatedPage: (input: GetAnnotatedPageInput) => Promise> + getDocumentContent: (input?: GetDocumentContentInput) => Promise> + getFields: () => Promise> + goTo: (input: GoToInput) => Promise + loadDocument: (input: LoadDocumentInput) => Promise + movePage: (input: MovePageInput) => Promise + rotatePage: (input: RotatePageInput) => Promise + selectTool: (input: SelectToolInput) => Promise + setFieldValue: (input: SetFieldValueInput) => Promise + submit: (input: SubmitInput) => Promise +} // `embed.events` — granular, EventEmitter-style subscription to the editor's outbound // events. `on(type, handler)` hands the handler that event's typed payload and returns @@ -180,20 +164,20 @@ export type IframeActions = { export type EmbedEvents = { on: ( type: TEventType, - handler: (data: EditorEventMap[TEventType]) => void - ) => () => void; -}; + handler: (data: EditorEventMap[TEventType]) => void, + ) => () => void +} // `embed.lifecycle` — teardown. export type EmbedLifecycle = { - dispose: () => void; -}; + dispose: () => void +} // The handle returned by createEmbed: three groups — `actions`, `events`, `lifecycle`. // Convention: name the variable `embed` (e.g. `embed.actions.goTo({ page })`, // `embed.events.on('SUBMISSION_SENT', …)`, `embed.lifecycle.dispose()`). export type Embed = { - actions: IframeActions; - events: EmbedEvents; - lifecycle: EmbedLifecycle; -}; + actions: IframeActions + events: EmbedEvents + lifecycle: EmbedLifecycle +} diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index eadd7d00..1c40f0dd 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -11,128 +11,100 @@ // table it reads) is downloaded otherwise. // CF: https://webmachinelearning.github.io/webmcp/ -import type { MethodName, WireType } from "./generated/contract"; -import { METHOD_NAMES } from "./generated/method-names"; -import { WEBMCP_TOOLS, type WebMCPToolRecord } from "./generated/webmcp-tools"; -import type { BridgeLogger } from "./logger"; -import type { BridgeResult } from "./types"; -import { modelContextCandidates } from "./webmcp-shared"; +import type { MethodName, WireType } from './generated/contract' +import { METHOD_NAMES } from './generated/method-names' +import { WEBMCP_TOOLS, type WebMCPToolRecord } from './generated/webmcp-tools' +import type { BridgeLogger } from './logger' +import type { BridgeResult } from './types' +import { modelContextCandidates } from './webmcp-shared' // The MCP tool-result envelope. The specification serializes whatever `execute` // resolves with as JSON text; this shape is what runtimes that map results onto MCP's // CallToolResult read (and what the editor's own in-page tools return): a failed // Result additionally flagged `isError`, a page render carried as an `image` block. -type ToolContent = - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: "image/png" }; -type CallToolResult = { content: ToolContent[]; isError?: boolean }; -type WebMCPTool = Omit & { - execute: ( - input: unknown, - options?: { signal: AbortSignal } - ) => Promise; -}; +type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: 'image/png' } +type CallToolResult = { content: ToolContent[]; isError?: boolean } +type WebMCPTool = Omit & { + execute: (input: unknown, options?: { signal: AbortSignal }) => Promise +} type ModelContext = { - registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown; -}; + registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown +} -const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; +const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' const isModelContext = (value: unknown): value is ModelContext => - typeof value === "object" && - value !== null && - "registerTool" in value && - typeof value.registerTool === "function"; + typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' -const readModelContext = (): ModelContext | null => - modelContextCandidates().find(isModelContext) ?? null; +const readModelContext = (): ModelContext | null => modelContextCandidates().find(isModelContext) ?? null const toTextToolResult = (result: BridgeResult): CallToolResult => ({ - content: [{ type: "text", text: JSON.stringify(result) }], + content: [{ type: 'text', text: JSON.stringify(result) }], ...(result.success ? {} : { isError: true }), -}); +}) // The render travels once, as the image block a vision-capable runtime shows the // model; the text block keeps the rest of the result (page, size, badges). Anything // but a successful PNG render takes the plain text envelope. -const toAnnotatedPageToolResult = ( - result: BridgeResult -): CallToolResult => { +const toAnnotatedPageToolResult = (result: BridgeResult): CallToolResult => { if (!result.success) { - return toTextToolResult(result); + return toTextToolResult(result) } - const render: unknown = result.data; - if ( - typeof render !== "object" || - render === null || - !("image_data_url" in render) - ) { - return toTextToolResult(result); + const render: unknown = result.data + if (typeof render !== 'object' || render === null || !('image_data_url' in render)) { + return toTextToolResult(result) } - const { image_data_url: imageDataUrl, ...renderWithoutImage } = render; - if ( - typeof imageDataUrl !== "string" || - !imageDataUrl.startsWith(PNG_DATA_URL_PREFIX) - ) { - return toTextToolResult(result); + const { image_data_url: imageDataUrl, ...renderWithoutImage } = render + if (typeof imageDataUrl !== 'string' || !imageDataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + return toTextToolResult(result) } return { content: [ - { - type: "image", - data: imageDataUrl.slice(PNG_DATA_URL_PREFIX.length), - mimeType: "image/png", - }, - { - type: "text", - text: JSON.stringify({ success: true, data: renderWithoutImage }), - }, + { type: 'image', data: imageDataUrl.slice(PNG_DATA_URL_PREFIX.length), mimeType: 'image/png' }, + { type: 'text', text: JSON.stringify({ success: true, data: renderWithoutImage }) }, ], - }; -}; + } +} // One arm per operation on purpose: a new operation must decide here whether its result // is text or a picture instead of taking the text envelope unnoticed. -const toCallToolResult = ( - wireType: WireType, - result: BridgeResult -): CallToolResult => { +const toCallToolResult = (wireType: WireType, result: BridgeResult): CallToolResult => { switch (wireType) { - case "GET_ANNOTATED_PAGE": - return toAnnotatedPageToolResult(result); - case "BATCH_SET_FIELD_VALUE": - case "CREATE_FIELD": - case "DELETE_FIELDS": - case "DELETE_PAGES": - case "DETECT_FIELDS": - case "DOWNLOAD": - case "FOCUS_FIELD": - case "GET_DOCUMENT_CONTENT": - case "GET_FIELDS": - case "GO_TO": - case "LOAD_DOCUMENT": - case "MOVE_PAGE": - case "ROTATE_PAGE": - case "SELECT_TOOL": - case "SET_FIELD_VALUE": - case "SUBMIT": - return toTextToolResult(result); + case 'GET_ANNOTATED_PAGE': + return toAnnotatedPageToolResult(result) + case 'BATCH_SET_FIELD_VALUE': + case 'CREATE_FIELD': + case 'DELETE_FIELDS': + case 'DELETE_PAGES': + case 'DETECT_FIELDS': + case 'DOWNLOAD': + case 'FOCUS_FIELD': + case 'GET_DOCUMENT_CONTENT': + case 'GET_FIELDS': + case 'GO_TO': + case 'LOAD_DOCUMENT': + case 'MOVE_PAGE': + case 'ROTATE_PAGE': + case 'SELECT_TOOL': + case 'SET_FIELD_VALUE': + case 'SUBMIT': + return toTextToolResult(result) default: - wireType satisfies never; - return toTextToolResult(result); + wireType satisfies never + return toTextToolResult(result) } -}; +} // A model context is a page-level singleton keyed by tool name, so two embeds on one // page would collide; the first registration of a name wins and the rest are reported. // Each name records the signal that owns it, so only its owner ever frees it. -const liveTools = new Map(); +const liveTools = new Map() const freeTool = (name: string, owner: AbortSignal): void => { if (liveTools.get(name) === owner) { - liveTools.delete(name); + liveTools.delete(name) } -}; +} // Returns whether a usable model context was found (and the tools handed to it), so // the bridge can keep probing on later lifecycle transitions when it was not. @@ -144,31 +116,28 @@ export const registerWebMCPTools = ({ }: { // Resolves with the editor's wire-shaped Result (snake_case, what the record's // description promises), not the SDK's camelCased one. - dispatch: ( - wireType: WireType, - data: unknown - ) => Promise>; - exclude: readonly MethodName[]; - signal: AbortSignal; - logger: BridgeLogger; + dispatch: (wireType: WireType, data: unknown) => Promise> + exclude: readonly MethodName[] + signal: AbortSignal + logger: BridgeLogger }): boolean => { if (signal.aborted) { - return false; + return false } - const modelContext = readModelContext(); + const modelContext = readModelContext() if (modelContext === null) { - logger.info("webmcp.unavailable", { reason: "invalid_model_context" }); - return false; + logger.info('webmcp.unavailable', { reason: 'invalid_model_context' }) + return false } - const excluded = new Set(exclude); + const excluded = new Set(exclude) for (const method of METHOD_NAMES) { if (excluded.has(method)) { - continue; + continue } - const record = WEBMCP_TOOLS[method]; + const record = WEBMCP_TOOLS[method] if (liveTools.has(record.name)) { - logger.warn("webmcp.tool_already_registered", { tool: record.name }); - continue; + logger.warn('webmcp.tool_already_registered', { tool: record.name }) + continue } const tool: WebMCPTool = { name: record.name, @@ -179,30 +148,25 @@ export const registerWebMCPTools = ({ // was posted still runs there (the wire has no cancel frame). A nullish input becomes // an empty payload (the no-input operations' wire shape). execute: async (input, options) => { - options?.signal.throwIfAborted(); - return toCallToolResult( - record.wireType, - await dispatch(record.wireType, input ?? {}) - ); + options?.signal.throwIfAborted() + return toCallToolResult(record.wireType, await dispatch(record.wireType, input ?? {})) }, - }; - liveTools.set(tool.name, signal); - signal.addEventListener("abort", () => freeTool(tool.name, signal), { - once: true, - }); + } + liveTools.set(tool.name, signal) + signal.addEventListener('abort', () => freeTool(tool.name, signal), { once: true }) // Registration is best-effort: a runtime that rejects one tool must not take the // others down or escape as an unhandled rejection. void (async (): Promise => { try { - await modelContext.registerTool(tool, { signal }); + await modelContext.registerTool(tool, { signal }) } catch (error) { - freeTool(tool.name, signal); - logger.error("webmcp.register_tool_failed", { + freeTool(tool.name, signal) + logger.error('webmcp.register_tool_failed', { tool: tool.name, message: error instanceof Error ? error.message : String(error), - }); + }) } - })(); + })() } - return true; -}; + return true +} diff --git a/embed/test/bridge.test.ts b/embed/test/bridge.test.ts index fae9e696..8de61194 100644 --- a/embed/test/bridge.test.ts +++ b/embed/test/bridge.test.ts @@ -1,599 +1,473 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { attachEmbed } from "../src/bridge"; -import type { BridgeState, Embed } from "../src/types"; +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachEmbed } from '../src/bridge' +import type { BridgeState, Embed } from '../src/types' -const EDITOR_ORIGIN = "https://tenant.simplepdf.com"; +const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' -type Posted = { type: string; request_id: string; data: unknown }; +type Posted = { type: string; request_id: string; data: unknown } type Harness = { - embed: Embed; - posted: Posted[]; - reply: (message: unknown) => void; - lastRequestId: () => string; - contentWindow: Window; + embed: Embed + posted: Posted[] + reply: (message: unknown) => void + lastRequestId: () => string + contentWindow: Window // The lifecycle state machine is internal (no public getState); tests observe it // through onStateChange, the same hook createEmbed's load-when-ready gate uses. - getState: () => BridgeState; -}; + getState: () => BridgeState +} -const harnesses: Harness[] = []; +const harnesses: Harness[] = [] const makeHarness = (): Harness => { - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const contentWindow = iframe.contentWindow; + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow if (contentWindow === null) { - throw new Error("jsdom iframe has no contentWindow"); + throw new Error('jsdom iframe has no contentWindow') } - const posted: Posted[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); - const states: BridgeState[] = [{ kind: "booting" }]; + }) + const states: BridgeState[] = [{ kind: 'booting' }] const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, onStateChange: (state) => states.push(state), - }); + }) const reply = (message: unknown): void => { window.dispatchEvent( - new MessageEvent("message", { - data: JSON.stringify(message), - origin: EDITOR_ORIGIN, - source: contentWindow, - }) - ); - }; + new MessageEvent('message', { data: JSON.stringify(message), origin: EDITOR_ORIGIN, source: contentWindow }), + ) + } const lastRequestId = (): string => { - const last = posted[posted.length - 1]; + const last = posted[posted.length - 1] if (last === undefined) { - throw new Error("no message posted"); + throw new Error('no message posted') } - return last.request_id; - }; + return last.request_id + } const getState = (): BridgeState => { - const latest = states[states.length - 1]; + const latest = states[states.length - 1] if (latest === undefined) { - throw new Error("no state captured"); + throw new Error('no state captured') } - return latest; - }; - const harness: Harness = { - embed, - posted, - reply, - lastRequestId, - contentWindow, - getState, - }; - harnesses.push(harness); - return harness; -}; + return latest + } + const harness: Harness = { embed, posted, reply, lastRequestId, contentWindow, getState } + harnesses.push(harness) + return harness +} -const replyResult = ( - harness: Harness, - requestId: string, - result: unknown -): void => - harness.reply({ - type: "REQUEST_RESULT", - data: { request_id: requestId, result }, - }); +const replyResult = (harness: Harness, requestId: string, result: unknown): void => + harness.reply({ type: 'REQUEST_RESULT', data: { request_id: requestId, result } }) describe(attachEmbed.name, () => { afterEach(() => { for (const harness of harnesses) { - harness.embed.lifecycle.dispose(); + harness.embed.lifecycle.dispose() } - harnesses.length = 0; - document.body.innerHTML = ""; - vi.restoreAllMocks(); - vi.useRealTimers(); - }); + harnesses.length = 0 + document.body.innerHTML = '' + vi.restoreAllMocks() + vi.useRealTimers() + }) - it("posts the SCREAMING_SNAKE wire type and correlates the reply by request_id", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.getFields(); - const request = harness.posted[harness.posted.length - 1]; - expect(request?.type).toBe("GET_FIELDS"); - replyResult(harness, harness.lastRequestId(), { - success: true, - data: { fields: [] }, - }); - await expect(promise).resolves.toEqual({ - success: true, - data: { fields: [] }, - }); - }); + it('posts the SCREAMING_SNAKE wire type and correlates the reply by request_id', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.getFields() + const request = harness.posted[harness.posted.length - 1] + expect(request?.type).toBe('GET_FIELDS') + replyResult(harness, harness.lastRequestId(), { success: true, data: { fields: [] } }) + await expect(promise).resolves.toEqual({ success: true, data: { fields: [] } }) + }) - it("normalizes a void success ({ success: true }) to { success: true, data: null }", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.goTo({ page: 2 }); - replyResult(harness, harness.lastRequestId(), { success: true }); - await expect(promise).resolves.toEqual({ success: true, data: null }); - }); + it('normalizes a void success ({ success: true }) to { success: true, data: null }', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.goTo({ page: 2 }) + replyResult(harness, harness.lastRequestId(), { success: true }) + await expect(promise).resolves.toEqual({ success: true, data: null }) + }) - it("forwards a typed editor error and camelCases its details payload", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.submit({ downloadCopy: false }); + it('forwards a typed editor error and camelCases its details payload', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.submit({ downloadCopy: false }) // The wire sends snake_case details; the SDK surfaces them camelCased. replyResult(harness, harness.lastRequestId(), { success: false, error: { - code: "bad_request:missing_required_fields", - message: "2 required fields are unfilled", + code: 'bad_request:missing_required_fields', + message: '2 required fields are unfilled', details: { unfilled_required_fields_count: 2 }, }, - }); - const result = await promise; - expect(result.success).toBe(false); - if ( - !result.success && - result.error.code === "bad_request:missing_required_fields" - ) { - expect(result.error.details).toEqual({ unfilledRequiredFieldsCount: 2 }); + }) + const result = await promise + expect(result.success).toBe(false) + if (!result.success && result.error.code === 'bad_request:missing_required_fields') { + expect(result.error.details).toEqual({ unfilledRequiredFieldsCount: 2 }) } - }); + }) - it("transforms the camelCase request payload to snake_case on the wire", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.setFieldValue({ - fieldId: "f1", - value: "hello_world", - }); - const request = harness.posted[harness.posted.length - 1]; - expect(request?.type).toBe("SET_FIELD_VALUE"); + it('transforms the camelCase request payload to snake_case on the wire', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.setFieldValue({ fieldId: 'f1', value: 'hello_world' }) + const request = harness.posted[harness.posted.length - 1] + expect(request?.type).toBe('SET_FIELD_VALUE') // KEY fieldId -> field_id; the opaque value (with underscores) is untouched. - expect(request?.data).toEqual({ field_id: "f1", value: "hello_world" }); - replyResult(harness, harness.lastRequestId(), { success: true }); - await expect(promise).resolves.toEqual({ success: true, data: null }); - }); + expect(request?.data).toEqual({ field_id: 'f1', value: 'hello_world' }) + replyResult(harness, harness.lastRequestId(), { success: true }) + await expect(promise).resolves.toEqual({ success: true, data: null }) + }) - it("returns unexpected:iframe_not_mounted when the iframe is gone", async () => { - const embed = attachEmbed({ - getIframe: () => null, - editorOrigin: EDITOR_ORIGIN, - }); - const result = await embed.actions.getFields(); + it('returns unexpected:iframe_not_mounted when the iframe is gone', async () => { + const embed = attachEmbed({ getIframe: () => null, editorOrigin: EDITOR_ORIGIN }) + const result = await embed.actions.getFields() expect(result).toEqual({ success: false, - error: { - code: "unexpected:iframe_not_mounted", - message: "Editor iframe is not mounted", - }, - }); - embed.lifecycle.dispose(); - }); + error: { code: 'unexpected:iframe_not_mounted', message: 'Editor iframe is not mounted' }, + }) + embed.lifecycle.dispose() + }) - it("times out with unexpected:timeout after the generous dead-iframe budget", async () => { - vi.useFakeTimers(); - const harness = makeHarness(); - const promise = harness.embed.actions.getFields(); - await vi.advanceTimersByTimeAsync(60_001); - const result = await promise; - expect(result.success).toBe(false); + it('times out with unexpected:timeout after the generous dead-iframe budget', async () => { + vi.useFakeTimers() + const harness = makeHarness() + const promise = harness.embed.actions.getFields() + await vi.advanceTimersByTimeAsync(60_001) + const result = await promise + expect(result.success).toBe(false) if (!result.success) { - expect(result.error.code).toBe("unexpected:timeout"); + expect(result.error.code).toBe('unexpected:timeout') } - }); + }) - it("does not starve a request queued behind a slow one (resolves when replied within budget)", async () => { - vi.useFakeTimers(); - const harness = makeHarness(); - const slow = harness.embed.actions.detectFields(); - const slowId = harness.lastRequestId(); - const fast = harness.embed.actions.getFields(); - const fastId = harness.lastRequestId(); + it('does not starve a request queued behind a slow one (resolves when replied within budget)', async () => { + vi.useFakeTimers() + const harness = makeHarness() + const slow = harness.embed.actions.detectFields() + const slowId = harness.lastRequestId() + const fast = harness.embed.actions.getFields() + const fastId = harness.lastRequestId() // 30s elapses (well under the 60s budget); the slow op is still in flight. - await vi.advanceTimersByTimeAsync(30_000); - replyResult(harness, fastId, { success: true, data: { fields: [] } }); - await expect(fast).resolves.toEqual({ - success: true, - data: { fields: [] }, - }); - replyResult(harness, slowId, { - success: true, - data: { detected_count: 1 }, - }); - await expect(slow).resolves.toEqual({ - success: true, - data: { detectedCount: 1 }, - }); - }); + await vi.advanceTimersByTimeAsync(30_000) + replyResult(harness, fastId, { success: true, data: { fields: [] } }) + await expect(fast).resolves.toEqual({ success: true, data: { fields: [] } }) + replyResult(harness, slowId, { success: true, data: { detected_count: 1 } }) + await expect(slow).resolves.toEqual({ success: true, data: { detectedCount: 1 } }) + }) - it("rejects pending requests with unexpected:bridge_disposed on dispose", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.getFields(); - harness.embed.lifecycle.dispose(); + it('rejects pending requests with unexpected:bridge_disposed on dispose', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.getFields() + harness.embed.lifecycle.dispose() await expect(promise).resolves.toEqual({ success: false, - error: { - code: "unexpected:bridge_disposed", - message: "Editor bridge was disposed", - }, - }); - }); + error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, + }) + }) - it("returns unexpected:bridge_disposed for a call made after dispose (without posting)", async () => { - const harness = makeHarness(); - harness.embed.lifecycle.dispose(); - const postedBefore = harness.posted.length; - const result = await harness.embed.actions.getFields(); + it('returns unexpected:bridge_disposed for a call made after dispose (without posting)', async () => { + const harness = makeHarness() + harness.embed.lifecycle.dispose() + const postedBefore = harness.posted.length + const result = await harness.embed.actions.getFields() expect(result).toEqual({ success: false, - error: { - code: "unexpected:bridge_disposed", - message: "Editor bridge was disposed", - }, - }); - expect(harness.posted.length).toBe(postedBefore); - }); + error: { code: 'unexpected:bridge_disposed', message: 'Editor bridge was disposed' }, + }) + expect(harness.posted.length).toBe(postedBefore) + }) - it("returns unexpected:unknown when posting throws (dead contentWindow)", async () => { - const harness = makeHarness(); - vi.spyOn(harness.contentWindow, "postMessage").mockImplementation(() => { - throw new Error("dead window"); - }); - const result = await harness.embed.actions.getFields(); - expect(result.success).toBe(false); + it('returns unexpected:unknown when posting throws (dead contentWindow)', async () => { + const harness = makeHarness() + vi.spyOn(harness.contentWindow, 'postMessage').mockImplementation(() => { + throw new Error('dead window') + }) + const result = await harness.embed.actions.getFields() + expect(result.success).toBe(false) if (!result.success) { - expect(result.error.code).toBe("unexpected:unknown"); + expect(result.error.code).toBe('unexpected:unknown') } - }); + }) - it("returns unexpected:malformed_result when the editor reply has no valid result", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.getFields(); - replyResult(harness, harness.lastRequestId(), { nonsense: true }); - const result = await promise; - expect(result.success).toBe(false); + it('returns unexpected:malformed_result when the editor reply has no valid result', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.getFields() + replyResult(harness, harness.lastRequestId(), { nonsense: true }) + const result = await promise + expect(result.success).toBe(false) if (!result.success) { - expect(result.error.code).toBe("unexpected:malformed_result"); + expect(result.error.code).toBe('unexpected:malformed_result') } - }); + }) - it("delivers SUBMISSION_SENT to on(type) with the verbatim snake_case payload", () => { - const harness = makeHarness(); - const listener = vi.fn(); - harness.embed.events.on("SUBMISSION_SENT", listener); - harness.reply({ - type: "SUBMISSION_SENT", - data: { document_id: "doc_1", submission_id: "sub_1" }, - }); - expect(listener).toHaveBeenCalledWith({ - document_id: "doc_1", - submission_id: "sub_1", - }); - }); + it('delivers SUBMISSION_SENT to on(type) with the verbatim snake_case payload', () => { + const harness = makeHarness() + const listener = vi.fn() + harness.embed.events.on('SUBMISSION_SENT', listener) + harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1', submission_id: 'sub_1' } }) + expect(listener).toHaveBeenCalledWith({ document_id: 'doc_1', submission_id: 'sub_1' }) + }) - it("delivers the EDITOR_READY and DOCUMENT_LOADED lifecycle events to on(type)", () => { - const harness = makeHarness(); - const onReady = vi.fn(); - const onLoaded = vi.fn(); - harness.embed.events.on("EDITOR_READY", onReady); - harness.embed.events.on("DOCUMENT_LOADED", onLoaded); - harness.reply({ type: "EDITOR_READY", data: {} }); - expect(onReady).toHaveBeenCalledWith({}); - harness.reply({ type: "DOCUMENT_LOADED", data: { document_id: "doc_9" } }); - expect(onLoaded).toHaveBeenCalledWith({ document_id: "doc_9" }); - }); + it('delivers the EDITOR_READY and DOCUMENT_LOADED lifecycle events to on(type)', () => { + const harness = makeHarness() + const onReady = vi.fn() + const onLoaded = vi.fn() + harness.embed.events.on('EDITOR_READY', onReady) + harness.embed.events.on('DOCUMENT_LOADED', onLoaded) + harness.reply({ type: 'EDITOR_READY', data: {} }) + expect(onReady).toHaveBeenCalledWith({}) + harness.reply({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc_9' } }) + expect(onLoaded).toHaveBeenCalledWith({ document_id: 'doc_9' }) + }) - it("keeps notifying handlers of the same event when one throws", () => { - const harness = makeHarness(); - const second = vi.fn(); - harness.embed.events.on("SUBMISSION_SENT", () => { - throw new Error("listener boom"); - }); - harness.embed.events.on("SUBMISSION_SENT", second); - harness.reply({ - type: "SUBMISSION_SENT", - data: { document_id: "doc_1", submission_id: "sub_1" }, - }); - expect(second).toHaveBeenCalledWith({ - document_id: "doc_1", - submission_id: "sub_1", - }); - }); + it('keeps notifying handlers of the same event when one throws', () => { + const harness = makeHarness() + const second = vi.fn() + harness.embed.events.on('SUBMISSION_SENT', () => { + throw new Error('listener boom') + }) + harness.embed.events.on('SUBMISSION_SENT', second) + harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1', submission_id: 'sub_1' } }) + expect(second).toHaveBeenCalledWith({ document_id: 'doc_1', submission_id: 'sub_1' }) + }) - it("drops a malformed SUBMISSION_SENT payload", () => { - const harness = makeHarness(); - const listener = vi.fn(); - harness.embed.events.on("SUBMISSION_SENT", listener); - harness.reply({ type: "SUBMISSION_SENT", data: { document_id: "doc_1" } }); - expect(listener).not.toHaveBeenCalled(); - }); + it('drops a malformed SUBMISSION_SENT payload', () => { + const harness = makeHarness() + const listener = vi.fn() + harness.embed.events.on('SUBMISSION_SENT', listener) + harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1' } }) + expect(listener).not.toHaveBeenCalled() + }) - it("unsubscribes when the returned disposer is called", () => { - const harness = makeHarness(); - const listener = vi.fn(); - const off = harness.embed.events.on("SUBMISSION_SENT", listener); - off(); - harness.reply({ - type: "SUBMISSION_SENT", - data: { document_id: "doc_1", submission_id: "sub_1" }, - }); - expect(listener).not.toHaveBeenCalled(); - }); + it('unsubscribes when the returned disposer is called', () => { + const harness = makeHarness() + const listener = vi.fn() + const off = harness.embed.events.on('SUBMISSION_SENT', listener) + off() + harness.reply({ type: 'SUBMISSION_SENT', data: { document_id: 'doc_1', submission_id: 'sub_1' } }) + expect(listener).not.toHaveBeenCalled() + }) - it("delivers PAGE_FOCUSED to on(type) verbatim (null previous_page allowed)", () => { - const harness = makeHarness(); - const listener = vi.fn(); - harness.embed.events.on("PAGE_FOCUSED", listener); - harness.reply({ - type: "PAGE_FOCUSED", - data: { previous_page: null, current_page: 1, total_pages: 3 }, - }); - expect(listener).toHaveBeenCalledWith({ - previous_page: null, - current_page: 1, - total_pages: 3, - }); - }); + it('delivers PAGE_FOCUSED to on(type) verbatim (null previous_page allowed)', () => { + const harness = makeHarness() + const listener = vi.fn() + harness.embed.events.on('PAGE_FOCUSED', listener) + harness.reply({ type: 'PAGE_FOCUSED', data: { previous_page: null, current_page: 1, total_pages: 3 } }) + expect(listener).toHaveBeenCalledWith({ previous_page: null, current_page: 1, total_pages: 3 }) + }) - it("flips to editorReady then documentLoaded from probe responses", () => { - const harness = makeHarness(); - expect(harness.getState().kind).toBe("booting"); - const probe = harness.posted.find( - (message) => message.type === "GET_FIELDS" - ); - expect(probe).toBeDefined(); + it('flips to editorReady then documentLoaded from probe responses', () => { + const harness = makeHarness() + expect(harness.getState().kind).toBe('booting') + const probe = harness.posted.find((message) => message.type === 'GET_FIELDS') + expect(probe).toBeDefined() if (probe !== undefined) { - replyResult(harness, probe.request_id, { - success: true, - data: { fields: [] }, - }); + replyResult(harness, probe.request_id, { success: true, data: { fields: [] } }) } - expect(harness.getState().kind).toBe("documentLoaded"); - }); + expect(harness.getState().kind).toBe('documentLoaded') + }) - it("reports readiness via onStateChange when reached only through the probe (no editor event)", () => { + it('reports readiness via onStateChange when reached only through the probe (no editor event)', () => { // This is what drives createEmbed's "load the document once ready" gate: readiness // reached via the probe emits NO editor event, so onStateChange must still fire. - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const contentWindow = iframe.contentWindow; + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow if (contentWindow === null) { - throw new Error("jsdom iframe has no contentWindow"); + throw new Error('jsdom iframe has no contentWindow') } - const posted: Posted[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); - const states: string[] = []; + }) + const states: string[] = [] const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, onStateChange: (state) => states.push(state.kind), - }); - const probe = posted.find((message) => message.type === "GET_FIELDS"); + }) + const probe = posted.find((message) => message.type === 'GET_FIELDS') if (probe !== undefined) { window.dispatchEvent( - new MessageEvent("message", { + new MessageEvent('message', { data: JSON.stringify({ - type: "REQUEST_RESULT", - data: { - request_id: probe.request_id, - result: { success: true, data: { fields: [] } }, - }, + type: 'REQUEST_RESULT', + data: { request_id: probe.request_id, result: { success: true, data: { fields: [] } } }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }) - ); + }), + ) } - expect(states).toContain("documentLoaded"); - embed.lifecycle.dispose(); - }); + expect(states).toContain('documentLoaded') + embed.lifecycle.dispose() + }) - it("clears the readiness fallback once a document is loaded (no late warning or corruption)", () => { - vi.useFakeTimers(); - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const contentWindow = iframe.contentWindow; + it('clears the readiness fallback once a document is loaded (no late warning or corruption)', () => { + vi.useFakeTimers() + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow if (contentWindow === null) { - throw new Error("jsdom iframe has no contentWindow"); + throw new Error('jsdom iframe has no contentWindow') } - const posted: Posted[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); - const warn = vi.fn(); - const states: string[] = []; + }) + const warn = vi.fn() + const states: string[] = [] const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, logger: { debug: () => {}, info: () => {}, warn, error: () => {} }, onStateChange: (state) => states.push(state.kind), - }); - const probe = posted.find((message) => message.type === "GET_FIELDS"); + }) + const probe = posted.find((message) => message.type === 'GET_FIELDS') if (probe !== undefined) { window.dispatchEvent( - new MessageEvent("message", { + new MessageEvent('message', { data: JSON.stringify({ - type: "REQUEST_RESULT", - data: { - request_id: probe.request_id, - result: { success: true, data: { fields: [] } }, - }, + type: 'REQUEST_RESULT', + data: { request_id: probe.request_id, result: { success: true, data: { fields: [] } } }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }) - ); + }), + ) } - expect(states.at(-1)).toBe("documentLoaded"); + expect(states.at(-1)).toBe('documentLoaded') // The fallback timer was cleared on load: advancing past it neither warns nor // drops the loaded document back to editorReady. - vi.advanceTimersByTime(31_000); - expect(states.at(-1)).toBe("documentLoaded"); - expect(warn).not.toHaveBeenCalled(); - embed.lifecycle.dispose(); - }); + vi.advanceTimersByTime(31_000) + expect(states.at(-1)).toBe('documentLoaded') + expect(warn).not.toHaveBeenCalled() + embed.lifecycle.dispose() + }) - it("stops the readiness probe loop after the fallback window (bounded probing)", () => { - vi.useFakeTimers(); - const harness = makeHarness(); + it('stops the readiness probe loop after the fallback window (bounded probing)', () => { + vi.useFakeTimers() + const harness = makeHarness() // The editor never confirms readiness; probes fire every 500ms until the // 30s fallback, then stop. - vi.advanceTimersByTime(30_000); - const countAtFallback = harness.posted.filter( - (message) => message.type === "GET_FIELDS" - ).length; - expect(countAtFallback).toBeGreaterThan(1); - vi.advanceTimersByTime(30_000); - const countLater = harness.posted.filter( - (message) => message.type === "GET_FIELDS" - ).length; - expect(countLater).toBe(countAtFallback); - expect(harness.getState().kind).toBe("editorReady"); - }); + vi.advanceTimersByTime(30_000) + const countAtFallback = harness.posted.filter((message) => message.type === 'GET_FIELDS').length + expect(countAtFallback).toBeGreaterThan(1) + vi.advanceTimersByTime(30_000) + const countLater = harness.posted.filter((message) => message.type === 'GET_FIELDS').length + expect(countLater).toBe(countAtFallback) + expect(harness.getState().kind).toBe('editorReady') + }) - it("a throwing logger never affects bridge behavior", async () => { - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const contentWindow = iframe.contentWindow; + it('a throwing logger never affects bridge behavior', async () => { + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow if (contentWindow === null) { - throw new Error("jsdom iframe has no contentWindow"); + throw new Error('jsdom iframe has no contentWindow') } - const posted: Posted[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); + }) const boom = () => { - throw new Error("log boom"); - }; - const throwingLogger = { debug: boom, info: boom, warn: boom, error: boom }; - const embed = attachEmbed({ - getIframe: () => iframe, - editorOrigin: EDITOR_ORIGIN, - logger: throwingLogger, - }); - const promise = embed.actions.getFields(); - const requestId = posted[posted.length - 1]?.request_id ?? ""; + throw new Error('log boom') + } + const throwingLogger = { debug: boom, info: boom, warn: boom, error: boom } + const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, logger: throwingLogger }) + const promise = embed.actions.getFields() + const requestId = posted[posted.length - 1]?.request_id ?? '' window.dispatchEvent( - new MessageEvent("message", { + new MessageEvent('message', { data: JSON.stringify({ - type: "REQUEST_RESULT", - data: { - request_id: requestId, - result: { success: true, data: { fields: [] } }, - }, + type: 'REQUEST_RESULT', + data: { request_id: requestId, result: { success: true, data: { fields: [] } } }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }) - ); - await expect(promise).resolves.toEqual({ - success: true, - data: { fields: [] }, - }); + }), + ) + await expect(promise).resolves.toEqual({ success: true, data: { fields: [] } }) // Disposal must not throw either. - expect(() => embed.lifecycle.dispose()).not.toThrow(); - }); + expect(() => embed.lifecycle.dispose()).not.toThrow() + }) - it("an async-rejecting logger never surfaces as an unhandled rejection", async () => { - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const contentWindow = iframe.contentWindow; + it('an async-rejecting logger never surfaces as an unhandled rejection', async () => { + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow if (contentWindow === null) { - throw new Error("jsdom iframe has no contentWindow"); + throw new Error('jsdom iframe has no contentWindow') } - const posted: Posted[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); - const unhandled = vi.fn(); - process.on("unhandledRejection", unhandled); - const rejecting = () => Promise.reject(new Error("async log boom")); + }) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + const rejecting = () => Promise.reject(new Error('async log boom')) const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, - logger: { - debug: rejecting, - info: rejecting, - warn: rejecting, - error: rejecting, - }, - }); - const promise = embed.actions.getFields(); - const requestId = posted[posted.length - 1]?.request_id ?? ""; + logger: { debug: rejecting, info: rejecting, warn: rejecting, error: rejecting }, + }) + const promise = embed.actions.getFields() + const requestId = posted[posted.length - 1]?.request_id ?? '' window.dispatchEvent( - new MessageEvent("message", { + new MessageEvent('message', { data: JSON.stringify({ - type: "REQUEST_RESULT", - data: { - request_id: requestId, - result: { success: true, data: { fields: [] } }, - }, + type: 'REQUEST_RESULT', + data: { request_id: requestId, result: { success: true, data: { fields: [] } } }, }), origin: EDITOR_ORIGIN, source: contentWindow, - }) - ); - await expect(promise).resolves.toEqual({ - success: true, - data: { fields: [] }, - }); + }), + ) + await expect(promise).resolves.toEqual({ success: true, data: { fields: [] } }) // Let any microtask-queued rejections flush. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(unhandled).not.toHaveBeenCalled(); - process.off("unhandledRejection", unhandled); - embed.lifecycle.dispose(); - }); + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandled).not.toHaveBeenCalled() + process.off('unhandledRejection', unhandled) + embed.lifecycle.dispose() + }) - it("ignores messages from a foreign origin", async () => { - const harness = makeHarness(); - const promise = harness.embed.actions.getFields(); + it('ignores messages from a foreign origin', async () => { + const harness = makeHarness() + const promise = harness.embed.actions.getFields() window.dispatchEvent( - new MessageEvent("message", { + new MessageEvent('message', { data: JSON.stringify({ - type: "REQUEST_RESULT", - data: { - request_id: harness.lastRequestId(), - result: { success: true, data: { fields: [] } }, - }, + type: 'REQUEST_RESULT', + data: { request_id: harness.lastRequestId(), result: { success: true, data: { fields: [] } } }, }), - origin: "https://evil.example.com", + origin: 'https://evil.example.com', source: harness.contentWindow, - }) - ); + }), + ) // The foreign-origin reply is ignored; the request stays pending until disposed. - harness.embed.lifecycle.dispose(); - const result = await promise; - expect(result.success).toBe(false); - }); + harness.embed.lifecycle.dispose() + const result = await promise + expect(result.success).toBe(false) + }) - it("disposes idempotently, invoking onDispose exactly once", () => { - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const onDispose = vi.fn(); - const embed = attachEmbed({ - getIframe: () => iframe, - editorOrigin: EDITOR_ORIGIN, - onDispose, - }); - embed.lifecycle.dispose(); - embed.lifecycle.dispose(); - expect(onDispose).toHaveBeenCalledTimes(1); - }); -}); + it('disposes idempotently, invoking onDispose exactly once', () => { + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const onDispose = vi.fn() + const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, onDispose }) + embed.lifecycle.dispose() + embed.lifecycle.dispose() + expect(onDispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/embed/test/case-transform.test.ts b/embed/test/case-transform.test.ts index bce2057c..6bc9cc26 100644 --- a/embed/test/case-transform.test.ts +++ b/embed/test/case-transform.test.ts @@ -1,75 +1,56 @@ -import { describe, expect, it } from "vitest"; -import { fromWireData, toWireData } from "../src/case-transform"; - -describe("case-transform", () => { - it("camelCases wire keys deeply (nested objects + arrays)", () => { - expect( - fromWireData({ fields: [{ field_id: "f1", total_pages: 3 }] }) - ).toEqual({ - fields: [{ fieldId: "f1", totalPages: 3 }], - }); - }); - - it("snake_cases SDK keys for the wire", () => { - expect( - toWireData({ fieldId: "f1", downloadCopy: true, dataUrl: "d" }) - ).toEqual({ - field_id: "f1", +import { describe, expect, it } from 'vitest' +import { fromWireData, toWireData } from '../src/case-transform' + +describe('case-transform', () => { + it('camelCases wire keys deeply (nested objects + arrays)', () => { + expect(fromWireData({ fields: [{ field_id: 'f1', total_pages: 3 }] })).toEqual({ + fields: [{ fieldId: 'f1', totalPages: 3 }], + }) + }) + + it('snake_cases SDK keys for the wire', () => { + expect(toWireData({ fieldId: 'f1', downloadCopy: true, dataUrl: 'd' })).toEqual({ + field_id: 'f1', download_copy: true, - data_url: "d", - }); - }); - - it("round-trips SDK -> wire -> SDK", () => { - const sdk = { - fieldIds: ["a", "b"], - extractionMode: "plain", - page: 2, - value: null, - }; - expect(fromWireData(toWireData(sdk))).toEqual(sdk); - }); - - it("transforms KEYS only — opaque values (even underscored) are untouched", () => { - expect( - toWireData({ fieldId: "f", value: "a_snake_looking_value" }) - ).toEqual({ - field_id: "f", - value: "a_snake_looking_value", - }); - expect(fromWireData({ field_id: "f", value: "leave_me_be" })).toEqual({ - fieldId: "f", - value: "leave_me_be", - }); - }); - - it("passes primitives, null, and undefined through", () => { - expect(toWireData(null)).toBe(null); - expect(toWireData(undefined)).toBe(undefined); - expect(toWireData("x")).toBe("x"); - expect(fromWireData(42)).toBe(42); - }); - - it("passes arrays of primitives through unchanged", () => { - expect(toWireData([1, "a", true, null])).toEqual([1, "a", true, null]); - }); - - it("does not flatten non-plain objects (Date) — passes the instance through", () => { - const when = new Date(0); + data_url: 'd', + }) + }) + + it('round-trips SDK -> wire -> SDK', () => { + const sdk = { fieldIds: ['a', 'b'], extractionMode: 'plain', page: 2, value: null } + expect(fromWireData(toWireData(sdk))).toEqual(sdk) + }) + + it('transforms KEYS only — opaque values (even underscored) are untouched', () => { + expect(toWireData({ fieldId: 'f', value: 'a_snake_looking_value' })).toEqual({ + field_id: 'f', + value: 'a_snake_looking_value', + }) + expect(fromWireData({ field_id: 'f', value: 'leave_me_be' })).toEqual({ fieldId: 'f', value: 'leave_me_be' }) + }) + + it('passes primitives, null, and undefined through', () => { + expect(toWireData(null)).toBe(null) + expect(toWireData(undefined)).toBe(undefined) + expect(toWireData('x')).toBe('x') + expect(fromWireData(42)).toBe(42) + }) + + it('passes arrays of primitives through unchanged', () => { + expect(toWireData([1, 'a', true, null])).toEqual([1, 'a', true, null]) + }) + + it('does not flatten non-plain objects (Date) — passes the instance through', () => { + const when = new Date(0) // A flattened Date would become {}, which would not deep-equal the Date. - expect(toWireData({ createdAt: when })).toEqual({ created_at: when }); - }); - - it("does not allow prototype pollution through a __proto__ key", () => { - const malicious: unknown = JSON.parse( - '{"__proto__": {"polluted": true}, "fieldId": "f1"}' - ); - const out = toWireData(malicious); - const polluted: unknown = Object.getOwnPropertyDescriptor( - Object.prototype, - "polluted" - ); - expect(polluted).toBeUndefined(); - expect(out).toEqual({ field_id: "f1" }); - }); -}); + expect(toWireData({ createdAt: when })).toEqual({ created_at: when }) + }) + + it('does not allow prototype pollution through a __proto__ key', () => { + const malicious: unknown = JSON.parse('{"__proto__": {"polluted": true}, "fieldId": "f1"}') + const out = toWireData(malicious) + const polluted: unknown = Object.getOwnPropertyDescriptor(Object.prototype, 'polluted') + expect(polluted).toBeUndefined() + expect(out).toEqual({ field_id: 'f1' }) + }) +}) diff --git a/embed/test/helpers.ts b/embed/test/helpers.ts index ec63d2c4..823ea02c 100644 --- a/embed/test/helpers.ts +++ b/embed/test/helpers.ts @@ -1,13 +1,12 @@ -import { vi } from "vitest"; -import type { BridgeResult, Embed, IframeActions } from "../src/types"; +import { vi } from 'vitest' +import type { BridgeResult, Embed, IframeActions } from '../src/types' -const okResult: BridgeResult = { success: true, data: null }; +const okResult: BridgeResult = { success: true, data: null } // A fully-stubbed actions group: every editor operation is a vi.fn resolving to a // success Result. Shared by the tools + adapter tests. export const makeActionsStub = (): IframeActions => { - const method = (): Promise> => - Promise.resolve(okResult); + const method = (): Promise> => Promise.resolve(okResult) return { createField: vi.fn(method), deleteFields: vi.fn(method), @@ -25,8 +24,8 @@ export const makeActionsStub = (): IframeActions => { selectTool: vi.fn(method), setFieldValue: vi.fn(method), submit: vi.fn(method), - }; -}; + } +} // A minimal Embed handle wrapping stubbed actions (events + lifecycle are no-ops); // enough for adapters that only dispatch through embed.actions. @@ -34,4 +33,4 @@ export const makeEmbedStub = (): Embed => ({ actions: makeActionsStub(), events: { on: () => () => {} }, lifecycle: { dispose: () => {} }, -}); +}) diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index a1a2061e..c8bdf5ae 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -1,546 +1,419 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - buildEditorDomain, - buildEditorURL, - createEmbed, - EmbedConfigError, - encodeContext, -} from "../src/mount"; -import type { Embed } from "../src/types"; +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildEditorDomain, buildEditorURL, createEmbed, EmbedConfigError, encodeContext } from '../src/mount' +import type { Embed } from '../src/types' // Recover the document URL from ?open= the same way the editor does. Kept here so this test pins the // CONTRACT both sides rely on - the SDK builds the ?open value and the editor reads it back - not just // the SDK half. const recoverDocumentUrlFromOpen = (requestUrl: string): string | null => { - const queryStart = requestUrl.indexOf("?"); - if (queryStart === -1) return null; - const match = requestUrl.slice(queryStart + 1).match(/(?:^|&)open=(.*)$/); - if (match === null) return null; - const rawOpenValue = match[1]; - const firstSegment = rawOpenValue.split("&")[0]; + const queryStart = requestUrl.indexOf('?') + if (queryStart === -1) return null + const match = requestUrl.slice(queryStart + 1).match(/(?:^|&)open=(.*)$/) + if (match === null) return null + const rawOpenValue = match[1] + const firstSegment = rawOpenValue.split('&')[0] const decode = (value: string): string => { try { - return decodeURIComponent(value); + return decodeURIComponent(value) } catch { - return value; + return value } - }; + } // No raw ? in the value: it is a single query param, so a + is an encoded space. A raw ? means the // URL carries its own query, which is kept verbatim. - const documentUrl = firstSegment.includes("?") - ? decode(rawOpenValue) - : decode(firstSegment.replace(/\+/g, " ")); - return documentUrl === "" ? null : documentUrl; -}; + const documentUrl = firstSegment.includes('?') ? decode(rawOpenValue) : decode(firstSegment.replace(/\+/g, ' ')) + return documentUrl === '' ? null : documentUrl +} describe(buildEditorURL.name, () => { const openFallbackRoundTrip = (documentUrl: string): string | null => { const editorUrl = buildEditorURL({ - editorOrigin: "https://acme.simplepdf.com", + editorOrigin: 'https://acme.simplepdf.com', locale: undefined, encodedContext: null, hasDocumentUrl: false, openFallbackUrl: documentUrl, - }); - const { pathname, search } = new URL(editorUrl); - return recoverDocumentUrlFromOpen(`${pathname}${search}`); - }; + }) + const { pathname, search } = new URL(editorUrl) + return recoverDocumentUrlFromOpen(`${pathname}${search}`) + } it.each([ - "https://cdn.example.com/report.pdf", + 'https://cdn.example.com/report.pdf', // A space in the consumer's URL is encoded as + by URLSearchParams; the editor must restore it. - "https://cdn.example.com/my file.pdf", + 'https://cdn.example.com/my file.pdf', // A literal + is encoded as %2B (not +), so it survives as a literal +. - "https://cdn.example.com/a+b.pdf", + 'https://cdn.example.com/a+b.pdf', // The document URL carries its own query - the whole thing is percent-encoded, so it round-trips. - "https://gen.example.com/doc?id=7&v=2", + 'https://gen.example.com/doc?id=7&v=2', // A base64 ?context= (contains a literal +) in the document's OWN query. - "https://gen.example.com/doc?context=eyJjdXN0b21lcklkIjoiY3VzXzlmMyIsImNpdHkiOiLQnNC+0YHQutCy0LAifQ==", - ])( - "the ?open fallback URL round-trips through the editor parser: %s", - (documentUrl) => { - expect(openFallbackRoundTrip(documentUrl)).toBe(documentUrl); - } - ); + 'https://gen.example.com/doc?context=eyJjdXN0b21lcklkIjoiY3VzXzlmMyIsImNpdHkiOiLQnNC+0YHQutCy0LAifQ==', + ])('the ?open fallback URL round-trips through the editor parser: %s', (documentUrl) => { + expect(openFallbackRoundTrip(documentUrl)).toBe(documentUrl) + }) - it("encodes context so the editor decodes it back to the original object", () => { - const context = { customerId: "cus_9f3", ref: "invoice-42" }; + it('encodes context so the editor decodes it back to the original object', () => { + const context = { customerId: 'cus_9f3', ref: 'invoice-42' } const editorUrl = buildEditorURL({ - editorOrigin: "https://acme.simplepdf.com", + editorOrigin: 'https://acme.simplepdf.com', locale: undefined, encodedContext: encodeContext(context), hasDocumentUrl: false, - openFallbackUrl: "https://cdn.example.com/f.pdf", - }); + openFallbackUrl: 'https://cdn.example.com/f.pdf', + }) // The editor URL-decodes the context param once, then base64-decodes it back to the object. - const contextParam = new URL(editorUrl).searchParams.get("context"); - expect(contextParam).not.toBeNull(); - expect(JSON.parse(atob(decodeURIComponent(contextParam ?? "")))).toEqual( - context - ); - }); -}); + const contextParam = new URL(editorUrl).searchParams.get('context') + expect(contextParam).not.toBeNull() + expect(JSON.parse(atob(decodeURIComponent(contextParam ?? '')))).toEqual(context) + }) +}) describe(buildEditorDomain.name, () => { - it("uses https for production base domains", () => { - expect( - buildEditorDomain({ - companyIdentifier: "acme", - baseDomain: "simplepdf.com", - }) - ).toBe("https://acme.simplepdf.com"); - }); - - it("uses http for local/.nil base domains", () => { - expect( - buildEditorDomain({ - companyIdentifier: "acme", - baseDomain: "localhost:3000", - }) - ).toBe("http://acme.localhost:3000"); - expect( - buildEditorDomain({ companyIdentifier: "acme", baseDomain: "acme.nil" }) - ).toBe("http://acme.acme.nil"); - }); -}); + it('uses https for production base domains', () => { + expect(buildEditorDomain({ companyIdentifier: 'acme', baseDomain: 'simplepdf.com' })).toBe('https://acme.simplepdf.com') + }) + + it('uses http for local/.nil base domains', () => { + expect(buildEditorDomain({ companyIdentifier: 'acme', baseDomain: 'localhost:3000' })).toBe('http://acme.localhost:3000') + expect(buildEditorDomain({ companyIdentifier: 'acme', baseDomain: 'acme.nil' })).toBe('http://acme.acme.nil') + }) +}) describe(encodeContext.name, () => { - it("returns null for undefined context", () => { - expect(encodeContext(undefined)).toBeNull(); - }); - - it("base64-url-encodes a context object round-trip", () => { - const encoded = encodeContext({ ref: "abc", n: 1 }); - expect(encoded).not.toBeNull(); - const decoded = JSON.parse(atob(decodeURIComponent(encoded ?? ""))); - expect(decoded).toEqual({ ref: "abc", n: 1 }); - }); - - it("returns null (does not throw) on a non-Latin1 context (btoa limitation)", () => { - expect(encodeContext({ name: "café 😀 日本" })).toBeNull(); - }); -}); + it('returns null for undefined context', () => { + expect(encodeContext(undefined)).toBeNull() + }) + + it('base64-url-encodes a context object round-trip', () => { + const encoded = encodeContext({ ref: 'abc', n: 1 }) + expect(encoded).not.toBeNull() + const decoded = JSON.parse(atob(decodeURIComponent(encoded ?? ''))) + expect(decoded).toEqual({ ref: 'abc', n: 1 }) + }) + + it('returns null (does not throw) on a non-Latin1 context (btoa limitation)', () => { + expect(encodeContext({ name: 'café 😀 日本' })).toBeNull() + }) +}) describe(createEmbed.name, () => { - const mounted: Embed[] = []; + const mounted: Embed[] = [] afterEach(() => { for (const embed of mounted) { - embed.lifecycle.dispose(); + embed.lifecycle.dispose() } - mounted.length = 0; - document.body.innerHTML = ""; - }); + mounted.length = 0 + document.body.innerHTML = '' + }) // Defaults companyIdentifier so the document/target tests stay terse; pass companyIdentifier to override. - const mount = ( - args: Omit[0], "companyIdentifier"> & { - companyIdentifier?: string; - } - ): Embed => { - const embed = createEmbed({ companyIdentifier: "acme", ...args }); - mounted.push(embed); - return embed; - }; - - it("throws EmbedConfigError when the target selector matches nothing", () => { - expect(() => mount({ target: "#missing" })).toThrow(EmbedConfigError); - }); - - it("throws EmbedConfigError for an invalid companyIdentifier", () => { - document.body.innerHTML = '
'; - expect(() => - mount({ target: "#root", companyIdentifier: "Not A Label" }) - ).toThrow(/DNS label/); - }); + const mount = (args: Omit[0], 'companyIdentifier'> & { companyIdentifier?: string }): Embed => { + const embed = createEmbed({ companyIdentifier: 'acme', ...args }) + mounted.push(embed) + return embed + } + + it('throws EmbedConfigError when the target selector matches nothing', () => { + expect(() => mount({ target: '#missing' })).toThrow(EmbedConfigError) + }) + + it('throws EmbedConfigError for an invalid companyIdentifier', () => { + document.body.innerHTML = '
' + expect(() => mount({ target: '#root', companyIdentifier: 'Not A Label' })).toThrow(/DNS label/) + }) // Mount with a document source against a fresh root (most document tests only // care that construction does/doesn't throw synchronously). - const mountWith = ( - doc: Parameters[0]["document"] - ): Embed => { - document.body.innerHTML = '
'; - return mount({ target: "#root", document: doc }); - }; + const mountWith = (doc: Parameters[0]['document']): Embed => { + document.body.innerHTML = '
' + return mount({ target: '#root', document: doc }) + } // --- Accepts (we don't gatekeep) ----------------------------------------- - it("accepts an http document url (mixed-content is the browser's gate, not ours)", () => { - expect(() => mountWith({ url: "http://example.com/a.pdf" })).not.toThrow(); - }); + it('accepts an http document url (mixed-content is the browser\'s gate, not ours)', () => { + expect(() => mountWith({ url: 'http://example.com/a.pdf' })).not.toThrow() + }) - it("accepts a credentialed document url (routes through the editor ?open loader)", () => { - expect(() => - mountWith({ url: "https://user:pass@example.com/a.pdf" }) - ).not.toThrow(); - }); - - it("accepts a Blob via { file }", () => { - const file = new File(["%PDF-1.4"], "form.pdf", { - type: "application/pdf", - }); - expect(() => mountWith({ file })).not.toThrow(); - }); - - it("accepts a data URL via { dataUrl }", () => { - expect(() => - mountWith({ dataUrl: "data:application/pdf;base64,AAAA" }) - ).not.toThrow(); - }); + it('accepts a credentialed document url (routes through the editor ?open loader)', () => { + expect(() => mountWith({ url: 'https://user:pass@example.com/a.pdf' })).not.toThrow() + }) + + it('accepts a Blob via { file }', () => { + const file = new File(['%PDF-1.4'], 'form.pdf', { type: 'application/pdf' }) + expect(() => mountWith({ file })).not.toThrow() + }) + + it('accepts a data URL via { dataUrl }', () => { + expect(() => mountWith({ dataUrl: 'data:application/pdf;base64,AAAA' })).not.toThrow() + }) // --- Rejects with a message that names the right arm ---------------------- - it("rejects a non-http(s) url scheme", () => { - expect(() => mountWith({ url: "ftp://example.com/a.pdf" })).toThrow( - "http(s)" - ); - }); + it('rejects a non-http(s) url scheme', () => { + expect(() => mountWith({ url: 'ftp://example.com/a.pdf' })).toThrow('http(s)') + }) - it("tells you to use { file } when a Blob is passed to url", () => { - const blob = new Blob(["%PDF-1.4"], { type: "application/pdf" }); + it('tells you to use { file } when a Blob is passed to url', () => { + const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' }) // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ url: blob })).toThrow("document: { file }"); - }); + expect(() => mountWith({ url: blob })).toThrow('document: { file }') + }) - it("tells you to use { dataUrl } when a data URL is passed to url", () => { - expect(() => - mountWith({ url: "data:application/pdf;base64,AAAA" }) - ).toThrow("document: { dataUrl }"); - }); + it('tells you to use { dataUrl } when a data URL is passed to url', () => { + expect(() => mountWith({ url: 'data:application/pdf;base64,AAAA' })).toThrow('document: { dataUrl }') + }) - it("tells you to use { file } when a blob: URL is passed to url", () => { - expect(() => mountWith({ url: "blob:https://example.com/abc" })).toThrow( - "document: { file }" - ); - }); + it('tells you to use { file } when a blob: URL is passed to url', () => { + expect(() => mountWith({ url: 'blob:https://example.com/abc' })).toThrow('document: { file }') + }) - it("tells you to use a url/dataUrl when a string is passed to file", () => { + it('tells you to use a url/dataUrl when a string is passed to file', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ file: "https://example.com/a.pdf" })).toThrow( - "document: { url }" - ); - }); + expect(() => mountWith({ file: 'https://example.com/a.pdf' })).toThrow('document: { url }') + }) - it("tells you to use { file } when a Blob is passed to dataUrl", () => { - const blob = new Blob(["x"], { type: "application/pdf" }); + it('tells you to use { file } when a Blob is passed to dataUrl', () => { + const blob = new Blob(['x'], { type: 'application/pdf' }) // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mountWith({ dataUrl: blob })).toThrow("document: { file }"); - }); + expect(() => mountWith({ dataUrl: blob })).toThrow('document: { file }') + }) - it("rejects a document with no source arm", () => { - expect(() => mountWith({})).toThrow("exactly one source"); - }); + it('rejects a document with no source arm', () => { + expect(() => mountWith({})).toThrow('exactly one source') + }) - it("rejects a document with more than one source arm", () => { + it('rejects a document with more than one source arm', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => - mountWith({ - url: "https://x.com/a.pdf", - dataUrl: "data:application/pdf;base64,AAAA", - }) - ).toThrow("exactly one source"); - }); + expect(() => mountWith({ url: 'https://x.com/a.pdf', dataUrl: 'data:application/pdf;base64,AAAA' })).toThrow( + 'exactly one source', + ) + }) it('accepts the reserved "embed" companyIdentifier (reserved-portal rejection is WEM-only)', () => { - document.body.innerHTML = '
'; - expect(() => - mount({ target: "#root", companyIdentifier: "embed" }) - ).not.toThrow(); - }); - - it("mounts an iframe under the target with the locale + context in the URL", () => { - document.body.innerHTML = '
'; - mount({ - target: "#root", - companyIdentifier: "acme", - locale: "fr", - context: { a: 1 }, - }); - const iframe = document.querySelector("#root iframe"); + document.body.innerHTML = '
' + expect(() => mount({ target: '#root', companyIdentifier: 'embed' })).not.toThrow() + }) + + it('mounts an iframe under the target with the locale + context in the URL', () => { + document.body.innerHTML = '
' + mount({ target: '#root', companyIdentifier: 'acme', locale: 'fr', context: { a: 1 } }) + const iframe = document.querySelector('#root iframe') if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error("expected an iframe under the target"); + throw new Error('expected an iframe under the target') } - const src = new URL(iframe.src); - expect(src.origin).toBe("https://acme.simplepdf.com"); - expect(src.pathname).toBe("/fr/editor"); - expect(src.searchParams.get("context")).not.toBeNull(); - }); - - it("navigates straight to a SimplePDF documents URL (preserving its query, appending context, bridging to its own subdomain)", () => { - document.body.innerHTML = '
'; + const src = new URL(iframe.src) + expect(src.origin).toBe('https://acme.simplepdf.com') + expect(src.pathname).toBe('/fr/editor') + expect(src.searchParams.get('context')).not.toBeNull() + }) + + it('navigates straight to a SimplePDF documents URL (preserving its query, appending context, bridging to its own subdomain)', () => { + document.body.innerHTML = '
' mount({ - target: "#root", - companyIdentifier: "acme", + target: '#root', + companyIdentifier: 'acme', context: { a: 1 }, - document: { - url: "https://demo.simplepdf.com/documents/abc-123?prefill=p-9", - }, - }); - const iframe = document.querySelector("#root iframe"); + document: { url: 'https://demo.simplepdf.com/documents/abc-123?prefill=p-9' }, + }) + const iframe = document.querySelector('#root iframe') if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error("expected an iframe under the target"); + throw new Error('expected an iframe under the target') } - const src = new URL(iframe.src); + const src = new URL(iframe.src) // Bridges to the URL's own subdomain (demo), not the configured companyIdentifier (acme). - expect(src.origin).toBe("https://demo.simplepdf.com"); - expect(src.pathname).toBe("/documents/abc-123"); - expect(src.searchParams.get("prefill")).toBe("p-9"); - expect(src.searchParams.get("context")).not.toBeNull(); + expect(src.origin).toBe('https://demo.simplepdf.com') + expect(src.pathname).toBe('/documents/abc-123') + expect(src.searchParams.get('prefill')).toBe('p-9') + expect(src.searchParams.get('context')).not.toBeNull() // It is a direct navigation, not the host-fetch /editor path. - expect(src.searchParams.get("loadingPlaceholder")).toBeNull(); - }); + expect(src.searchParams.get('loadingPlaceholder')).toBeNull() + }) - it("treats a /documents/ url off the base-domain family as a normal PDF url (builds /editor)", () => { - document.body.innerHTML = '
'; - mount({ - target: "#root", - companyIdentifier: "acme", - document: { url: "https://evil.com/documents/abc-123" }, - }); - const iframe = document.querySelector("#root iframe"); + it('treats a /documents/ url off the base-domain family as a normal PDF url (builds /editor)', () => { + document.body.innerHTML = '
' + mount({ target: '#root', companyIdentifier: 'acme', document: { url: 'https://evil.com/documents/abc-123' } }) + const iframe = document.querySelector('#root iframe') if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error("expected an iframe under the target"); + throw new Error('expected an iframe under the target') } - const src = new URL(iframe.src); - expect(src.origin).toBe("https://acme.simplepdf.com"); - expect(src.pathname).toBe("/en/editor"); - }); - - it("does not direct-load a documents URL on a nested subdomain (one tenant label only)", () => { - document.body.innerHTML = '
'; - mount({ - target: "#root", - companyIdentifier: "acme", - document: { url: "https://a.b.simplepdf.com/documents/abc" }, - }); - const iframe = document.querySelector("#root iframe"); + const src = new URL(iframe.src) + expect(src.origin).toBe('https://acme.simplepdf.com') + expect(src.pathname).toBe('/en/editor') + }) + + it('does not direct-load a documents URL on a nested subdomain (one tenant label only)', () => { + document.body.innerHTML = '
' + mount({ target: '#root', companyIdentifier: 'acme', document: { url: 'https://a.b.simplepdf.com/documents/abc' } }) + const iframe = document.querySelector('#root iframe') if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error("expected an iframe under the target"); + throw new Error('expected an iframe under the target') } // a.b.simplepdf.com is two labels above the base domain → not a tenant documents // URL → falls back to the configured tenant's /editor host-fetch path. - expect(new URL(iframe.src).origin).toBe("https://acme.simplepdf.com"); - expect(new URL(iframe.src).pathname).toBe("/en/editor"); - }); - - it("does not direct-load a documents URL at the apex base domain (a tenant subdomain is required)", () => { - document.body.innerHTML = '
'; - mount({ - target: "#root", - companyIdentifier: "acme", - document: { url: "https://simplepdf.com/documents/abc" }, - }); - const iframe = document.querySelector("#root iframe"); + expect(new URL(iframe.src).origin).toBe('https://acme.simplepdf.com') + expect(new URL(iframe.src).pathname).toBe('/en/editor') + }) + + it('does not direct-load a documents URL at the apex base domain (a tenant subdomain is required)', () => { + document.body.innerHTML = '
' + mount({ target: '#root', companyIdentifier: 'acme', document: { url: 'https://simplepdf.com/documents/abc' } }) + const iframe = document.querySelector('#root iframe') if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error("expected an iframe under the target"); + throw new Error('expected an iframe under the target') } - expect(new URL(iframe.src).pathname).toBe("/en/editor"); - }); + expect(new URL(iframe.src).pathname).toBe('/en/editor') + }) - it("does not direct-load a documents URL with extra path segments (anchored /documents/)", () => { - document.body.innerHTML = '
'; + it('does not direct-load a documents URL with extra path segments (anchored /documents/)', () => { + document.body.innerHTML = '
' mount({ - target: "#root", - companyIdentifier: "acme", - document: { url: "https://demo.simplepdf.com/documents/abc/extra" }, - }); - const iframe = document.querySelector("#root iframe"); + target: '#root', + companyIdentifier: 'acme', + document: { url: 'https://demo.simplepdf.com/documents/abc/extra' }, + }) + const iframe = document.querySelector('#root iframe') if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error("expected an iframe under the target"); + throw new Error('expected an iframe under the target') } - expect(new URL(iframe.src).pathname).toBe("/en/editor"); - }); - - it("removes the iframe it created on dispose", () => { - document.body.innerHTML = '
'; - const embed = createEmbed({ target: "#root", companyIdentifier: "acme" }); - expect(document.querySelector("#root iframe")).not.toBeNull(); - embed.lifecycle.dispose(); - expect(document.querySelector("#root iframe")).toBeNull(); - }); - - it("loads the document once readiness is reached via the probe (gate posts LOAD_DOCUMENT)", async () => { - vi.useFakeTimers(); - document.body.innerHTML = '
'; - mount({ - target: "#root", - document: { dataUrl: "data:application/pdf;base64,AAAA" }, - }); - const iframe = document.querySelector("#root iframe"); - if ( - !(iframe instanceof HTMLIFrameElement) || - iframe.contentWindow === null - ) { - throw new Error("expected an iframe with a contentWindow"); + expect(new URL(iframe.src).pathname).toBe('/en/editor') + }) + + it('removes the iframe it created on dispose', () => { + document.body.innerHTML = '
' + const embed = createEmbed({ target: '#root', companyIdentifier: 'acme' }) + expect(document.querySelector('#root iframe')).not.toBeNull() + embed.lifecycle.dispose() + expect(document.querySelector('#root iframe')).toBeNull() + }) + + it('loads the document once readiness is reached via the probe (gate posts LOAD_DOCUMENT)', async () => { + vi.useFakeTimers() + document.body.innerHTML = '
' + mount({ target: '#root', document: { dataUrl: 'data:application/pdf;base64,AAAA' } }) + const iframe = document.querySelector('#root iframe') + if (!(iframe instanceof HTMLIFrameElement) || iframe.contentWindow === null) { + throw new Error('expected an iframe with a contentWindow') } - const contentWindow = iframe.contentWindow; - const posted: { type: string; request_id: string }[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const contentWindow = iframe.contentWindow + const posted: { type: string; request_id: string }[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); + }) // Advance to a probe tick: readiness reached with NO EDITOR_READY / DOCUMENT_LOADED // event — only the probe. The readiness gate must still fire the deferred load. - await vi.advanceTimersByTimeAsync(500); - const probe = posted.find((message) => message.type === "GET_FIELDS"); + await vi.advanceTimersByTimeAsync(500) + const probe = posted.find((message) => message.type === 'GET_FIELDS') if (probe === undefined) { - throw new Error("expected a readiness probe"); + throw new Error('expected a readiness probe') } window.dispatchEvent( - new MessageEvent("message", { + new MessageEvent('message', { data: JSON.stringify({ - type: "REQUEST_RESULT", - data: { - request_id: probe.request_id, - result: { success: true, data: { fields: [] } }, - }, + type: 'REQUEST_RESULT', + data: { request_id: probe.request_id, result: { success: true, data: { fields: [] } } }, }), - origin: "https://acme.simplepdf.com", + origin: 'https://acme.simplepdf.com', source: contentWindow, - }) - ); + }), + ) // Flush the gate's microtask + the async data-URL resolution. - await vi.advanceTimersByTimeAsync(0); - expect(posted.some((message) => message.type === "LOAD_DOCUMENT")).toBe( - true - ); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it("attaches to an existing '; - const iframeCountBefore = document.querySelectorAll("iframe").length; - const embed = mount({ target: "#ed", companyIdentifier: "acme" }); + await vi.advanceTimersByTimeAsync(0) + expect(posted.some((message) => message.type === 'LOAD_DOCUMENT')).toBe(true) + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('attaches to an existing ' + const iframeCountBefore = document.querySelectorAll('iframe').length + const embed = mount({ target: '#ed', companyIdentifier: 'acme' }) // No new iframe is created: we bridge to the one you rendered. - expect(document.querySelectorAll("iframe").length).toBe(iframeCountBefore); + expect(document.querySelectorAll('iframe').length).toBe(iframeCountBefore) // dispose() must NOT remove an iframe the consumer owns. - embed.lifecycle.dispose(); - expect(document.querySelector("#ed")).not.toBeNull(); - }); - - it("rejects an attached iframe whose src origin does not match the companyIdentifier", () => { - document.body.innerHTML = - ''; - expect(() => mount({ target: "#ed", companyIdentifier: "acme" })).toThrow( - "https://other.simplepdf.com" - ); - }); - - it("rejects a documents URL when attaching to an existing iframe (mount-only feature)", () => { - document.body.innerHTML = - ''; + embed.lifecycle.dispose() + expect(document.querySelector('#ed')).not.toBeNull() + }) + + it('rejects an attached iframe whose src origin does not match the companyIdentifier', () => { + document.body.innerHTML = '' + expect(() => mount({ target: '#ed', companyIdentifier: 'acme' })).toThrow('https://other.simplepdf.com') + }) + + it('rejects a documents URL when attaching to an existing iframe (mount-only feature)', () => { + document.body.innerHTML = '' expect(() => - mount({ - target: "#ed", - companyIdentifier: "acme", - document: { url: "https://demo.simplepdf.com/documents/abc" }, - }) - ).toThrow(/documents URL/); - }); - - it("does not false-positive the origin guard when the iframe has no usable src", () => { - for (const html of [ - '', - '', - '', - ]) { - document.body.innerHTML = html; - expect(() => - mount({ target: "#x", companyIdentifier: "acme" }) - ).not.toThrow(); + mount({ target: '#ed', companyIdentifier: 'acme', document: { url: 'https://demo.simplepdf.com/documents/abc' } }), + ).toThrow(/documents URL/) + }) + + it('does not false-positive the origin guard when the iframe has no usable src', () => { + for (const html of ['', '', '']) { + document.body.innerHTML = html + expect(() => mount({ target: '#x', companyIdentifier: 'acme' })).not.toThrow() } - }); + }) - it("rejects a missing companyIdentifier", () => { + it('rejects a missing companyIdentifier', () => { // @ts-expect-error companyIdentifier is required; exercise the runtime guard for untyped JS callers - expect(() => createEmbed({ target: "#root" })).toThrow( - /companyIdentifier is required/ - ); - }); + expect(() => createEmbed({ target: '#root' })).toThrow(/companyIdentifier is required/) + }) - it("rejects a non-element, non-string target with a clean error", () => { + it('rejects a non-element, non-string target with a clean error', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => - createEmbed({ target: 123, companyIdentifier: "acme" }) - ).toThrow(/target must be/); - }); + expect(() => createEmbed({ target: 123, companyIdentifier: 'acme' })).toThrow(/target must be/) + }) - it("rejects a non-object document with a clean error", () => { - document.body.innerHTML = '
'; + it('rejects a non-object document with a clean error', () => { + document.body.innerHTML = '
' // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => mount({ target: "#root", document: null })).toThrow( - /document must be an object/ - ); - }); + expect(() => mount({ target: '#root', document: null })).toThrow(/document must be an object/) + }) - it("rejects a non-object args with a clean error", () => { + it('rejects a non-object args with a clean error', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(null)).toThrow(/config object/); - }); + expect(() => createEmbed(null)).toThrow(/config object/) + }) - it("rejects a non-string baseDomain with a clean error", () => { + it('rejects a non-string baseDomain with a clean error', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => - createEmbed({ - target: "#root", - companyIdentifier: "acme", - baseDomain: 123, - }) - ).toThrow(/baseDomain must be a string/); - }); + expect(() => createEmbed({ target: '#root', companyIdentifier: 'acme', baseDomain: 123 })).toThrow(/baseDomain must be a string/) + }) // Every malformed shape an untyped JS caller can produce fails loud: `exclude` is // the control that withholds irreversible operations, so it must never fail open. it.each([ - ["a bare boolean", true], - ["an object without enabled", { exclude: ["submit"] }], - ["a stringly-typed enabled", { enabled: "true" }], - ["a stringly-typed flag", "false"], - ["a number", 0], - ["null", null], - ["a string exclude", { enabled: true, exclude: "submit" }], - ["a non-string exclude entry", { enabled: true, exclude: ["submit", 7] }], - ])("throws EmbedConfigError when webMCP is %s", (_label, webMCP) => { - document.body.innerHTML = '
'; - const malformedArgs: unknown = { - target: "#root", - companyIdentifier: "acme", - webMCP, - }; + ['a bare boolean', true], + ['an object without enabled', { exclude: ['submit'] }], + ['a stringly-typed enabled', { enabled: 'true' }], + ['a stringly-typed flag', 'false'], + ['a number', 0], + ['null', null], + ['a string exclude', { enabled: true, exclude: 'submit' }], + ['a non-string exclude entry', { enabled: true, exclude: ['submit', 7] }], + ])('throws EmbedConfigError when webMCP is %s', (_label, webMCP) => { + document.body.innerHTML = '
' + const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', webMCP } // @ts-expect-error exercising the runtime guard for untyped JS callers expect(() => createEmbed(malformedArgs)).toThrow( - /webMCP must be \{ enabled: false \} or \{ enabled: true, exclude\?: MethodName\[\] \}/ - ); - }); + /webMCP must be \{ enabled: false \} or \{ enabled: true, exclude\?: MethodName\[\] \}/, + ) + }) it('throws EmbedConfigError when the exclude key itself is misspelled, so the typo cannot read as "nothing withheld"', () => { - document.body.innerHTML = '
'; - const misspelledKey: unknown = { - target: "#root", - companyIdentifier: "acme", - webMCP: { enabled: true, exlude: ["submit"] }, - }; + document.body.innerHTML = '
' + const misspelledKey: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, exlude: ['submit'] } } // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(misspelledKey)).toThrow( - /webMCP has no option exlude \(known: enabled, exclude\)/ - ); - }); - - it("throws EmbedConfigError when exclude names no tool, so a misspelled name cannot register the operation it meant to withhold", () => { - document.body.innerHTML = '
'; - const misspelled: unknown = { - target: "#root", - companyIdentifier: "acme", - webMCP: { enabled: true, exclude: ["sumbit"] }, - }; + expect(() => createEmbed(misspelledKey)).toThrow(/webMCP has no option exlude \(known: enabled, exclude\)/) + }) + + it('throws EmbedConfigError when exclude names no tool, so a misspelled name cannot register the operation it meant to withhold', () => { + document.body.innerHTML = '
' + const misspelled: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, exclude: ['sumbit'] } } // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(misspelled)).toThrow( - /webMCP\.exclude names no tool: sumbit \(known: batchSetFieldValue, createField/ - ); - }); -}); + expect(() => createEmbed(misspelled)).toThrow(/webMCP\.exclude names no tool: sumbit \(known: batchSetFieldValue, createField/) + }) +}) diff --git a/embed/test/tanstack-ai.test.ts b/embed/test/tanstack-ai.test.ts index deab2e81..4f2658cc 100644 --- a/embed/test/tanstack-ai.test.ts +++ b/embed/test/tanstack-ai.test.ts @@ -1,61 +1,48 @@ -import { describe, expect, it } from "vitest"; -import { - createSimplePDFTools, - simplePDFToolDefinitions, -} from "../src/tanstack-ai"; -import type { BridgeResult } from "../src/types"; -import { makeEmbedStub } from "./helpers"; +import { describe, expect, it } from 'vitest' +import { createSimplePDFTools, simplePDFToolDefinitions } from '../src/tanstack-ai' +import type { BridgeResult } from '../src/types' +import { makeEmbedStub } from './helpers' -describe("simplePDFToolDefinitions", () => { - it("returns the 16 agentic operations as execute-less definitions (loadDocument excluded)", () => { - const definitions = simplePDFToolDefinitions(); - expect(definitions).toHaveLength(16); - expect(definitions.map((definition) => definition.name)).not.toContain( - "loadDocument" - ); +describe('simplePDFToolDefinitions', () => { + it('returns the 16 agentic operations as execute-less definitions (loadDocument excluded)', () => { + const definitions = simplePDFToolDefinitions() + expect(definitions).toHaveLength(16) + expect(definitions.map((definition) => definition.name)).not.toContain('loadDocument') for (const definition of definitions) { - expect(typeof definition.description).toBe("string"); - expect(definition.inputSchema).toBeDefined(); + expect(typeof definition.description).toBe('string') + expect(definition.inputSchema).toBeDefined() } - }); -}); + }) +}) -describe("createSimplePDFTools", () => { - it("produces a client tool for each of the 16 agentic operations", () => { - const tools = createSimplePDFTools({ embed: makeEmbedStub() }); - expect(tools).toHaveLength(16); - expect(tools.every((tool) => typeof tool.execute === "function")).toBe( - true - ); - }); +describe('createSimplePDFTools', () => { + it('produces a client tool for each of the 16 agentic operations', () => { + const tools = createSimplePDFTools({ embed: makeEmbedStub() }) + expect(tools).toHaveLength(16) + expect(tools.every((tool) => typeof tool.execute === 'function')).toBe(true) + }) - it("binds each tool to the editor: a client call validates input + dispatches to the matching action", async () => { - const embed = makeEmbedStub(); - const goTo = createSimplePDFTools({ embed }).find( - (tool) => tool.name === "goTo" - ); + it('binds each tool to the editor: a client call validates input + dispatches to the matching action', async () => { + const embed = makeEmbedStub() + const goTo = createSimplePDFTools({ embed }).find((tool) => tool.name === 'goTo') if (goTo === undefined || goTo.execute === undefined) { - throw new Error("expected a goTo client tool with an execute"); + throw new Error('expected a goTo client tool with an execute') } - await goTo.execute({ page: 2 }); - expect(embed.actions.goTo).toHaveBeenCalledWith({ page: 2 }); - }); + await goTo.execute({ page: 2 }) + expect(embed.actions.goTo).toHaveBeenCalledWith({ page: 2 }) + }) - it("returns bad_request:invalid_input on schema-invalid input without dispatching", async () => { - const embed = makeEmbedStub(); - const goTo = createSimplePDFTools({ embed }).find( - (tool) => tool.name === "goTo" - ); + it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => { + const embed = makeEmbedStub() + const goTo = createSimplePDFTools({ embed }).find((tool) => tool.name === 'goTo') if (goTo === undefined || goTo.execute === undefined) { - throw new Error("expected a goTo client tool with an execute"); + throw new Error('expected a goTo client tool with an execute') } - const result: BridgeResult = await goTo.execute({ - page: "not-a-number", - }); - expect(result.success).toBe(false); + const result: BridgeResult = await goTo.execute({ page: 'not-a-number' }) + expect(result.success).toBe(false) if (!result.success) { - expect(result.error.code).toBe("bad_request:invalid_input"); + expect(result.error.code).toBe('bad_request:invalid_input') } - expect(embed.actions.goTo).not.toHaveBeenCalled(); - }); -}); + expect(embed.actions.goTo).not.toHaveBeenCalled() + }) +}) diff --git a/embed/test/tools.test.ts b/embed/test/tools.test.ts index 390b4fb7..955742bb 100644 --- a/embed/test/tools.test.ts +++ b/embed/test/tools.test.ts @@ -1,65 +1,65 @@ -import { describe, expect, it } from "vitest"; -import { - isSimplePDFToolName, - routeToolCall, - SIMPLEPDF_TOOLS, -} from "../src/tools"; -import { makeActionsStub } from "./helpers"; +import { describe, expect, it } from 'vitest' +import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS } from '../src/tools' +import { makeActionsStub } from './helpers' describe(isSimplePDFToolName.name, () => { - it("accepts agentic tool names", () => { - expect(isSimplePDFToolName("getFields")).toBe(true); - expect(isSimplePDFToolName("goTo")).toBe(true); - expect(isSimplePDFToolName("createField")).toBe(true); - }); + it('accepts agentic tool names', () => { + expect(isSimplePDFToolName('getFields')).toBe(true) + expect(isSimplePDFToolName('goTo')).toBe(true) + expect(isSimplePDFToolName('createField')).toBe(true) + }) - it("rejects loadDocument (host/setup, not an agentic tool) and unknown names", () => { - expect(isSimplePDFToolName("loadDocument")).toBe(false); - expect(isSimplePDFToolName("not_a_tool")).toBe(false); - expect(isSimplePDFToolName(42)).toBe(false); - }); -}); + it('rejects loadDocument (host/setup, not an agentic tool) and unknown names', () => { + expect(isSimplePDFToolName('loadDocument')).toBe(false) + expect(isSimplePDFToolName('not_a_tool')).toBe(false) + expect(isSimplePDFToolName(42)).toBe(false) + }) +}) -describe("SIMPLEPDF_TOOLS", () => { - it("exposes the 16 agentic operations with descriptions + input schemas (loadDocument excluded)", () => { - const names = Object.keys(SIMPLEPDF_TOOLS); - expect(names).toHaveLength(16); - expect(names).not.toContain("loadDocument"); +describe('SIMPLEPDF_TOOLS', () => { + it('exposes the 16 agentic operations with descriptions + input schemas (loadDocument excluded)', () => { + const names = Object.keys(SIMPLEPDF_TOOLS) + expect(names).toHaveLength(16) + expect(names).not.toContain('loadDocument') for (const definition of Object.values(SIMPLEPDF_TOOLS)) { - expect(typeof definition.description).toBe("string"); - expect(definition.inputSchema).toBeDefined(); + expect(typeof definition.description).toBe('string') + expect(definition.inputSchema).toBeDefined() } - }); -}); + }) + + it('carries each description on the tool only, so the model never receives it twice', () => { + for (const definition of Object.values(SIMPLEPDF_TOOLS)) { + expect(definition.inputSchema.description).toBeUndefined() + } + }) +}) describe(routeToolCall.name, () => { - it("validates input against the tool schema, then dispatches to the matching action", async () => { - const actions = makeActionsStub(); - await routeToolCall(actions, "goTo", { page: 2 }); - expect(actions.goTo).toHaveBeenCalledWith({ page: 2 }); - }); + it('validates input against the tool schema, then dispatches to the matching action', async () => { + const actions = makeActionsStub() + await routeToolCall(actions, 'goTo', { page: 2 }) + expect(actions.goTo).toHaveBeenCalledWith({ page: 2 }) + }) - it("returns bad_request:invalid_input on schema-invalid input without dispatching", async () => { - const actions = makeActionsStub(); - const result = await routeToolCall(actions, "goTo", { - page: "not-a-number", - }); - expect(result.success).toBe(false); + it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => { + const actions = makeActionsStub() + const result = await routeToolCall(actions, 'goTo', { page: 'not-a-number' }) + expect(result.success).toBe(false) if (!result.success) { - expect(result.error.code).toBe("bad_request:invalid_input"); + expect(result.error.code).toBe('bad_request:invalid_input') } - expect(actions.goTo).not.toHaveBeenCalled(); - }); + expect(actions.goTo).not.toHaveBeenCalled() + }) - it("routes getAnnotatedPage to its action with the validated page", async () => { - const actions = makeActionsStub(); - await routeToolCall(actions, "getAnnotatedPage", { page: 1 }); - expect(actions.getAnnotatedPage).toHaveBeenCalledWith({ page: 1 }); - }); + it('routes getAnnotatedPage to its action with the validated page', async () => { + const actions = makeActionsStub() + await routeToolCall(actions, 'getAnnotatedPage', { page: 1 }) + expect(actions.getAnnotatedPage).toHaveBeenCalledWith({ page: 1 }) + }) - it("dispatches no-input tools without requiring input", async () => { - const actions = makeActionsStub(); - await routeToolCall(actions, "getFields", undefined); - expect(actions.getFields).toHaveBeenCalledTimes(1); - }); -}); + it('dispatches no-input tools without requiring input', async () => { + const actions = makeActionsStub() + await routeToolCall(actions, 'getFields', undefined) + expect(actions.getFields).toHaveBeenCalledTimes(1) + }) +}) diff --git a/embed/test/unwrap.test.ts b/embed/test/unwrap.test.ts index b9506b24..b6935d5c 100644 --- a/embed/test/unwrap.test.ts +++ b/embed/test/unwrap.test.ts @@ -1,33 +1,27 @@ -import { describe, expect, it } from "vitest"; -import { BridgeUnwrapError, unwrap } from "../src/unwrap"; -import type { BridgeResult } from "../src/types"; +import { describe, expect, it } from 'vitest' +import { BridgeUnwrapError, unwrap } from '../src/unwrap' +import type { BridgeResult } from '../src/types' describe(unwrap.name, () => { - it("returns data for a successful result", () => { - const result: BridgeResult<{ detected_count: number }> = { - success: true, - data: { detected_count: 3 }, - }; - expect(unwrap(result)).toEqual({ detected_count: 3 }); - }); + it('returns data for a successful result', () => { + const result: BridgeResult<{ detected_count: number }> = { success: true, data: { detected_count: 3 } } + expect(unwrap(result)).toEqual({ detected_count: 3 }) + }) - it("throws BridgeUnwrapError carrying the typed error for a failed result", () => { + it('throws BridgeUnwrapError carrying the typed error for a failed result', () => { const result: BridgeResult = { success: false, - error: { - code: "forbidden:editing_not_allowed", - message: "Editing is disabled", - }, - }; + error: { code: 'forbidden:editing_not_allowed', message: 'Editing is disabled' }, + } try { - unwrap(result); - throw new Error("expected unwrap to throw"); + unwrap(result) + throw new Error('expected unwrap to throw') } catch (caught) { - expect(caught).toBeInstanceOf(BridgeUnwrapError); + expect(caught).toBeInstanceOf(BridgeUnwrapError) if (caught instanceof BridgeUnwrapError) { - expect(caught.error.code).toBe("forbidden:editing_not_allowed"); - expect(caught.message).toBe("Editing is disabled"); + expect(caught.error.code).toBe('forbidden:editing_not_allowed') + expect(caught.message).toBe('Editing is disabled') } } - }); -}); + }) +}) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index c17ee298..a6b10f24 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -1,632 +1,448 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { attachEmbed, type AttachEmbedArgs } from "../src/bridge"; -import type { BridgeLogger } from "../src/logger"; -import type { Embed } from "../src/types"; -import { METHOD_NAMES } from "../src/generated/method-names"; -import { WEBMCP_TOOLS } from "../src/generated/webmcp-tools"; +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachEmbed, type AttachEmbedArgs } from '../src/bridge' +import type { BridgeLogger } from '../src/logger' +import type { Embed } from '../src/types' +import { METHOD_NAMES } from '../src/generated/method-names' +import { WEBMCP_TOOLS } from '../src/generated/webmcp-tools' -const EDITOR_ORIGIN = "https://tenant.simplepdf.com"; +const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' // The slice of a WebMCP tool descriptor these tests read back. type RegisteredTool = { - name: string; - description: string; - inputSchema: { - type: string; - properties?: Record; - required?: readonly string[]; - }; - annotations: { - readOnlyHint?: boolean; - untrustedContentHint?: boolean; - destructiveHint?: boolean; - openWorldHint?: boolean; - }; + name: string + description: string + inputSchema: { type: string; properties?: Record; required?: readonly string[] } + annotations: { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } execute: ( input: unknown, - options?: { signal: AbortSignal } + options?: { signal: AbortSignal }, ) => Promise<{ - content: Array< - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string } - >; - isError?: boolean; - }>; -}; + content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> + isError?: boolean + }> +} type FakeModelContext = { - registerTool: ( - tool: RegisteredTool, - options: { signal: AbortSignal } - ) => void; - registered: RegisteredTool[]; - liveToolNames: () => string[]; -}; - -const originalDocumentModelContext = Object.getOwnPropertyDescriptor( - document, - "modelContext" -); -const originalNavigatorModelContext = Object.getOwnPropertyDescriptor( - navigator, - "modelContext" -); - -const restoreModelContext = ( - target: object, - descriptor: PropertyDescriptor | undefined -): void => { + registerTool: (tool: RegisteredTool, options: { signal: AbortSignal }) => void + registered: RegisteredTool[] + liveToolNames: () => string[] +} + +const originalDocumentModelContext = Object.getOwnPropertyDescriptor(document, 'modelContext') +const originalNavigatorModelContext = Object.getOwnPropertyDescriptor(navigator, 'modelContext') + +const restoreModelContext = (target: object, descriptor: PropertyDescriptor | undefined): void => { if (descriptor === undefined) { - Reflect.deleteProperty(target, "modelContext"); - return; + Reflect.deleteProperty(target, 'modelContext') + return } - Object.defineProperty(target, "modelContext", descriptor); -}; + Object.defineProperty(target, 'modelContext', descriptor) +} // A minimal native-like model context: it records registrations and drops a tool from // the live set when its registration signal aborts (the spec's unregister mechanism). const installModelContext = ( host: Document | Navigator, - { rejectTool }: { rejectTool?: string } = {} + { rejectTool }: { rejectTool?: string } = {}, ): FakeModelContext => { - const registered: RegisteredTool[] = []; - const liveTools = new Set(); + const registered: RegisteredTool[] = [] + const liveTools = new Set() const modelContext: FakeModelContext = { registerTool: (tool, { signal }) => { if (tool.name === rejectTool) { - throw new Error(`runtime rejected ${tool.name}`); + throw new Error(`runtime rejected ${tool.name}`) } - registered.push(tool); - liveTools.add(tool.name); - signal.addEventListener("abort", () => liveTools.delete(tool.name), { - once: true, - }); + registered.push(tool) + liveTools.add(tool.name) + signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true }) }, registered, liveToolNames: () => [...liveTools], - }; - Object.defineProperty(host, "modelContext", { - configurable: true, - value: modelContext, - }); - return modelContext; -}; - -const makeLogger = (): BridgeLogger => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}); - -type Posted = { type: string; request_id: string; data: unknown }; + } + Object.defineProperty(host, 'modelContext', { configurable: true, value: modelContext }) + return modelContext +} + +const makeLogger = (): BridgeLogger => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) + +type Posted = { type: string; request_id: string; data: unknown } type Harness = { - embed: Embed; - posted: Posted[]; - reply: (request: Posted, result: unknown) => void; + embed: Embed + posted: Posted[] + reply: (request: Posted, result: unknown) => void // Registration waits for the editor to be alive; these are the editor's lifecycle announcements. - markEditorReady: () => void; - markDocumentLoaded: () => void; -}; - -const harnesses: Harness[] = []; - -const makeHarness = ( - args: Pick -): Harness => { - const iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - const contentWindow = iframe.contentWindow; + markEditorReady: () => void + markDocumentLoaded: () => void +} + +const harnesses: Harness[] = [] + +const makeHarness = (args: Pick): Harness => { + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow if (contentWindow === null) { - throw new Error("jsdom iframe has no contentWindow"); + throw new Error('jsdom iframe has no contentWindow') } - const posted: Posted[] = []; - vi.spyOn(contentWindow, "postMessage").mockImplementation( - (message: unknown) => { - if (typeof message === "string") { - posted.push(JSON.parse(message)); - } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) } - ); - const embed = attachEmbed({ - getIframe: () => iframe, - editorOrigin: EDITOR_ORIGIN, - ...args, - }); + }) + const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, ...args }) const receive = (message: unknown): void => { window.dispatchEvent( - new MessageEvent("message", { - data: JSON.stringify(message), - origin: EDITOR_ORIGIN, - source: contentWindow, - }) - ); - }; + new MessageEvent('message', { data: JSON.stringify(message), origin: EDITOR_ORIGIN, source: contentWindow }), + ) + } const harness: Harness = { embed, posted, - reply: (request, result) => - receive({ - type: "REQUEST_RESULT", - data: { request_id: request.request_id, result }, - }), - markEditorReady: () => receive({ type: "EDITOR_READY", data: {} }), - markDocumentLoaded: () => - receive({ type: "DOCUMENT_LOADED", data: { document_id: "doc1" } }), - }; - harnesses.push(harness); - return harness; -}; + reply: (request, result) => receive({ type: 'REQUEST_RESULT', data: { request_id: request.request_id, result } }), + markEditorReady: () => receive({ type: 'EDITOR_READY', data: {} }), + markDocumentLoaded: () => receive({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc1' } }), + } + harnesses.push(harness) + return harness +} // A ready embed with the option on: registration is asynchronous (the WebMCP module // is lazy-loaded), so callers wait for the expected tool count rather than reading it // synchronously. -const mountReady = ( - args: Pick -): Harness => { - const harness = makeHarness(args); - harness.markEditorReady(); - return harness; -}; - -const waitForTools = ( - modelContext: FakeModelContext, - count: number -): Promise => - vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)); - -const TOOL_COUNT = METHOD_NAMES.length; -const toolName = (method: keyof typeof WEBMCP_TOOLS): string => - WEBMCP_TOOLS[method].name; +const mountReady = (args: Pick): Harness => { + const harness = makeHarness(args) + harness.markEditorReady() + return harness +} + +const waitForTools = (modelContext: FakeModelContext, count: number): Promise => + vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)) + +const TOOL_COUNT = METHOD_NAMES.length +const toolName = (method: keyof typeof WEBMCP_TOOLS): string => WEBMCP_TOOLS[method].name // The bridge's readiness probe posts its own GET_FIELDS requests while the editor is // booting, so a tool call's request is located by type rather than by position. -const waitForRequest = async ( - harness: Harness, - type: string -): Promise => { - await vi.waitFor(() => - expect(harness.posted.some((message) => message.type === type)).toBe(true) - ); - const request = harness.posted.find((message) => message.type === type); +const waitForRequest = async (harness: Harness, type: string): Promise => { + await vi.waitFor(() => expect(harness.posted.some((message) => message.type === type)).toBe(true)) + const request = harness.posted.find((message) => message.type === type) if (request === undefined) { - throw new Error(`no ${type} request posted`); + throw new Error(`no ${type} request posted`) } - return request; -}; + return request +} -const findTool = ( - modelContext: FakeModelContext, - name: string -): RegisteredTool => { - const tool = modelContext.registered.find( - (candidate) => candidate.name === name - ); +const findTool = (modelContext: FakeModelContext, name: string): RegisteredTool => { + const tool = modelContext.registered.find((candidate) => candidate.name === name) if (tool === undefined) { - throw new Error(`tool ${name} was not registered`); + throw new Error(`tool ${name} was not registered`) } - return tool; -}; + return tool +} -describe("attachEmbed({ webMCP })", () => { +describe('attachEmbed({ webMCP })', () => { afterEach(() => { for (const harness of harnesses) { - harness.embed.lifecycle.dispose(); + harness.embed.lifecycle.dispose() } - harnesses.length = 0; - document.body.innerHTML = ""; - restoreModelContext(document, originalDocumentModelContext); - restoreModelContext(navigator, originalNavigatorModelContext); - vi.restoreAllMocks(); - }); - - it("registers every operation on document.modelContext as the manifest tool record: prefixed name, description, snake_case input schema and behavior hints", async () => { - const modelContext = installModelContext(document); - mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); + harnesses.length = 0 + document.body.innerHTML = '' + restoreModelContext(document, originalDocumentModelContext) + restoreModelContext(navigator, originalNavigatorModelContext) + vi.restoreAllMocks() + }) + + it('registers every operation on document.modelContext as the manifest tool record: prefixed name, description, snake_case input schema and behavior hints', async () => { + const modelContext = installModelContext(document) + mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual( Object.values(WEBMCP_TOOLS) .map((tool) => tool.name) - .sort() - ); - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); + .sort(), + ) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) // loadDocument is a host-page tool like it is in the editor's own registration. - expect(modelContext.liveToolNames()).toContain( - "simplepdf_embed_load_document" - ); - const setFieldValue = findTool( - modelContext, - "simplepdf_embed_set_field_value" - ); - expect(setFieldValue.description).toMatch( - /^Set the value of an existing field/ - ); - expect(setFieldValue.inputSchema.type).toBe("object"); - expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual([ - "animate", - "field_id", - "value", - ]); - expect(setFieldValue.inputSchema.required).toEqual(["field_id", "value"]); + expect(modelContext.liveToolNames()).toContain('simplepdf_embed_load_document') + const setFieldValue = findTool(modelContext, 'simplepdf_embed_set_field_value') + expect(setFieldValue.description).toMatch(/^Set the value of an existing field/) + expect(setFieldValue.inputSchema.type).toBe('object') + expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual(['animate', 'field_id', 'value']) + expect(setFieldValue.inputSchema.required).toEqual(['field_id', 'value']) for (const tool of modelContext.registered) { - const hasExplicitHint = - tool.annotations.readOnlyHint === true || - typeof tool.annotations.destructiveHint === "boolean"; - expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe( - true - ); + const hasExplicitHint = tool.annotations.readOnlyHint === true || typeof tool.annotations.destructiveHint === 'boolean' + expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe(true) } // The readers hand document-derived content to the agent: read-only AND untrusted. - expect( - findTool(modelContext, "simplepdf_embed_get_fields").annotations - ).toEqual({ + expect(findTool(modelContext, 'simplepdf_embed_get_fields').annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true, - }); - expect( - findTool(modelContext, "simplepdf_embed_get_annotated_page").annotations - ).toEqual({ + }) + expect(findTool(modelContext, 'simplepdf_embed_get_annotated_page').annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true, - }); - expect( - findTool(modelContext, "simplepdf_embed_submit").annotations - ).toEqual({ destructiveHint: true }); + }) + expect(findTool(modelContext, 'simplepdf_embed_submit').annotations).toEqual({ destructiveHint: true }) // The hints are the manifest's, openWorldHint included (the editor fetches an agent-supplied URL). - expect( - findTool(modelContext, "simplepdf_embed_set_field_value").annotations - ).toEqual({ + expect(findTool(modelContext, 'simplepdf_embed_set_field_value').annotations).toEqual({ destructiveHint: false, openWorldHint: true, - }); - }); + }) + }) - it("waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe", async () => { - const modelContext = installModelContext(document); - const booting = makeHarness({ webMCP: { enabled: true } }); + it('waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe', async () => { + const modelContext = installModelContext(document) + const booting = makeHarness({ webMCP: { enabled: true } }) // Control: a ready embed on the same context proves the lazy path had time to run; // every live name is the control's, so the booting embed registered nothing. - const control = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); - control.embed.lifecycle.dispose(); - expect(modelContext.liveToolNames()).toEqual([]); - - booting.markEditorReady(); - await waitForTools(modelContext, TOOL_COUNT * 2); - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); - }); - - it("withholds the excluded operations and registers the rest", async () => { - const modelContext = installModelContext(document); - const logger = makeLogger(); - mountReady({ - webMCP: { - enabled: true, - exclude: ["submit", "deletePages", "movePage", "rotatePage"], - }, - logger, - }); - await waitForTools(modelContext, TOOL_COUNT - 4); - - const names = modelContext.registered.map((tool) => tool.name); - expect(names).toContain(toolName("setFieldValue")); - expect(names).toContain(toolName("getFields")); - expect(names).not.toContain(toolName("submit")); - expect(names).not.toContain(toolName("deletePages")); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it("executes a tool call as the operation request on the wire and returns the editor Result, wire-shaped, as a JSON-text tool result", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - const pendingResult = findTool( - modelContext, - "simplepdf_embed_set_field_value" - ).execute({ field_id: "f1", value: "Jane" }); - const request = await waitForRequest(harness, "SET_FIELD_VALUE"); - expect(request.data).toEqual({ field_id: "f1", value: "Jane" }); - harness.reply(request, { success: true }); - const toolResult = await pendingResult; - expect(toolResult.isError).toBeUndefined(); - expect(toolResult.content).toEqual([ - { type: "text", text: JSON.stringify({ success: true, data: null }) }, - ]); - }); - - it("hands the agent the wire-shaped result its tool description promises (snake_case, not the SDK camelCase)", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - const pendingResult = findTool( - modelContext, - "simplepdf_embed_create_field" - ).execute({ type: "TEXT", x: 1, y: 2, width: 3, height: 4, page: 1 }); - const request = await waitForRequest(harness, "CREATE_FIELD"); - harness.reply(request, { success: true, data: { field_id: "f_new" } }); - const toolResult = await pendingResult; - expect(toolResult.content).toEqual([ - { - type: "text", - text: JSON.stringify({ success: true, data: { field_id: "f_new" } }), - }, - ]); - }); - - it("returns the annotated page render once, as an image block, with the badges map in the text block", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - const pendingResult = findTool( - modelContext, - "simplepdf_embed_get_annotated_page" - ).execute({ page: 1 }); - const request = await waitForRequest(harness, "GET_ANNOTATED_PAGE"); + const control = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + control.embed.lifecycle.dispose() + expect(modelContext.liveToolNames()).toEqual([]) + + booting.markEditorReady() + await waitForTools(modelContext, TOOL_COUNT * 2) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + }) + + it('withholds the excluded operations and registers the rest', async () => { + const modelContext = installModelContext(document) + const logger = makeLogger() + mountReady({ webMCP: { enabled: true, exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger }) + await waitForTools(modelContext, TOOL_COUNT - 4) + + const names = modelContext.registered.map((tool) => tool.name) + expect(names).toContain(toolName('setFieldValue')) + expect(names).toContain(toolName('getFields')) + expect(names).not.toContain(toolName('submit')) + expect(names).not.toContain(toolName('deletePages')) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('executes a tool call as the operation request on the wire and returns the editor Result, wire-shaped, as a JSON-text tool result', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_set_field_value').execute({ field_id: 'f1', value: 'Jane' }) + const request = await waitForRequest(harness, 'SET_FIELD_VALUE') + expect(request.data).toEqual({ field_id: 'f1', value: 'Jane' }) + harness.reply(request, { success: true }) + const toolResult = await pendingResult + expect(toolResult.isError).toBeUndefined() + expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify({ success: true, data: null }) }]) + }) + + it('hands the agent the wire-shaped result its tool description promises (snake_case, not the SDK camelCase)', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_create_field').execute({ type: 'TEXT', x: 1, y: 2, width: 3, height: 4, page: 1 }) + const request = await waitForRequest(harness, 'CREATE_FIELD') + harness.reply(request, { success: true, data: { field_id: 'f_new' } }) + const toolResult = await pendingResult + expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify({ success: true, data: { field_id: 'f_new' } }) }]) + }) + + it('returns the annotated page render once, as an image block, with the badges map in the text block', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_get_annotated_page').execute({ page: 1 }) + const request = await waitForRequest(harness, 'GET_ANNOTATED_PAGE') harness.reply(request, { success: true, - data: { - page: 1, - image_data_url: "data:image/png;base64,iVBORw0KGgo=", - image_width: 10, - image_height: 12, - badges: { "1": "f1" }, - }, - }); - const toolResult = await pendingResult; - expect(toolResult.isError).toBeUndefined(); + data: { page: 1, image_data_url: 'data:image/png;base64,iVBORw0KGgo=', image_width: 10, image_height: 12, badges: { '1': 'f1' } }, + }) + const toolResult = await pendingResult + expect(toolResult.isError).toBeUndefined() expect(toolResult.content).toEqual([ - { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, - { - type: "text", - text: JSON.stringify({ - success: true, - data: { - page: 1, - image_width: 10, - image_height: 12, - badges: { "1": "f1" }, - }, - }), - }, - ]); - }); - - it("keeps the text envelope for a failed annotated page render", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - const pendingResult = findTool( - modelContext, - "simplepdf_embed_get_annotated_page" - ).execute({ page: 99 }); - const request = await waitForRequest(harness, "GET_ANNOTATED_PAGE"); - const failure = { - success: false, - error: { code: "bad_request:page_out_of_range", message: "no page 99" }, - }; - harness.reply(request, failure); - const toolResult = await pendingResult; - expect(toolResult.isError).toBe(true); - expect(toolResult.content).toEqual([ - { type: "text", text: JSON.stringify(failure) }, - ]); - }); - - it("flags a failed editor Result as an error tool result that still carries the error code", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - const pendingResult = findTool( - modelContext, - "simplepdf_embed_go_to" - ).execute({ page: 99 }); - const request = await waitForRequest(harness, "GO_TO"); - harness.reply(request, { - success: false, - error: { code: "bad_request:page_out_of_range", message: "no page 99" }, - }); - const toolResult = await pendingResult; - expect(toolResult.isError).toBe(true); + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'text', text: JSON.stringify({ success: true, data: { page: 1, image_width: 10, image_height: 12, badges: { '1': 'f1' } } }) }, + ]) + }) + + it('keeps the text envelope for a failed annotated page render', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_get_annotated_page').execute({ page: 99 }) + const request = await waitForRequest(harness, 'GET_ANNOTATED_PAGE') + const failure = { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } } + harness.reply(request, failure) + const toolResult = await pendingResult + expect(toolResult.isError).toBe(true) + expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify(failure) }]) + }) + + it('flags a failed editor Result as an error tool result that still carries the error code', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_go_to').execute({ page: 99 }) + const request = await waitForRequest(harness, 'GO_TO') + harness.reply(request, { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } }) + const toolResult = await pendingResult + expect(toolResult.isError).toBe(true) expect(toolResult.content).toEqual([ { - type: "text", - text: JSON.stringify({ - success: false, - error: { - code: "bad_request:page_out_of_range", - message: "no page 99", - }, - }), + type: 'text', + text: JSON.stringify({ success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } }), }, - ]); - }); + ]) + }) - it("rejects a call whose signal is already aborted and posts nothing to the editor", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); + it('rejects a call whose signal is already aborted and posts nothing to the editor', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) - const aborted = new AbortController(); - aborted.abort(); - const postedBefore = harness.posted.length; + const aborted = new AbortController() + aborted.abort() + const postedBefore = harness.posted.length await expect( - findTool(modelContext, "simplepdf_embed_submit").execute( - { download_copy: false }, - { signal: aborted.signal } - ) - ).rejects.toMatchObject({ name: "AbortError" }); - expect(harness.posted).toHaveLength(postedBefore); - }); - - it("sends an empty payload when a no-input tool is called without arguments", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - void findTool(modelContext, "simplepdf_embed_detect_fields").execute( - undefined - ); - const request = await waitForRequest(harness, "DETECT_FIELDS"); - expect(request.data).toEqual({}); - }); - - it("unregisters every tool when the embed is disposed", async () => { - const modelContext = installModelContext(document); - const harness = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); - - harness.embed.lifecycle.dispose(); - expect(modelContext.liveToolNames()).toEqual([]); - }); - - it("registers nothing when the embed is disposed before the lazy module resolves", async () => { - const modelContext = installModelContext(document); - const disposedEarly = mountReady({ webMCP: { enabled: true } }); - disposedEarly.embed.lifecycle.dispose(); + findTool(modelContext, 'simplepdf_embed_submit').execute({ download_copy: false }, { signal: aborted.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(harness.posted).toHaveLength(postedBefore) + }) + + it('sends an empty payload when a no-input tool is called without arguments', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + void findTool(modelContext, 'simplepdf_embed_detect_fields').execute(undefined) + const request = await waitForRequest(harness, 'DETECT_FIELDS') + expect(request.data).toEqual({}) + }) + + it('unregisters every tool when the embed is disposed', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + + harness.embed.lifecycle.dispose() + expect(modelContext.liveToolNames()).toEqual([]) + }) + + it('registers nothing when the embed is disposed before the lazy module resolves', async () => { + const modelContext = installModelContext(document) + const disposedEarly = mountReady({ webMCP: { enabled: true } }) + disposedEarly.embed.lifecycle.dispose() // Control: a later embed on the same context registers its full set, proving the // early one's lazy load had every chance to run and registered nothing. - mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); - }); - - it("registers nothing when the option is off, even with a model context present", async () => { - const modelContext = installModelContext(document); - const registerTool = vi.spyOn(modelContext, "registerTool"); - mountReady({}); - mountReady({ webMCP: { enabled: false } }); + mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + }) + + it('registers nothing when the option is off, even with a model context present', async () => { + const modelContext = installModelContext(document) + const registerTool = vi.spyOn(modelContext, 'registerTool') + mountReady({}) + mountReady({ webMCP: { enabled: false } }) // Control: a ready embed with the option on registers, proving the off ones had // the same chance and took none of it. - mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT); - }); - - it("lets the first embed on a page own each tool name and reports the collision for a second one", async () => { - const modelContext = installModelContext(document); - const logger = makeLogger(); - const first = mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - - mountReady({ webMCP: { enabled: true }, logger }); + mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT) + }) + + it('lets the first embed on a page own each tool name and reports the collision for a second one', async () => { + const modelContext = installModelContext(document) + const logger = makeLogger() + const first = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + mountReady({ webMCP: { enabled: true }, logger }) await vi.waitFor(() => - expect(logger.warn).toHaveBeenCalledWith( - "webmcp.tool_already_registered", - { tool: "simplepdf_embed_submit" } - ) - ); - expect(logger.warn).toHaveBeenCalledTimes(TOOL_COUNT); - expect(modelContext.registered).toHaveLength(TOOL_COUNT); + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_submit' }), + ) + expect(logger.warn).toHaveBeenCalledTimes(TOOL_COUNT) + expect(modelContext.registered).toHaveLength(TOOL_COUNT) // Disposing the owner frees the names for the next embed. - first.embed.lifecycle.dispose(); - mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT * 2); - }); + first.embed.lifecycle.dispose() + mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT * 2) + }) - it("frees a rejected name only for its owner, so a later embed that took the name keeps it", async () => { + it('frees a rejected name only for its owner, so a later embed that took the name keeps it', async () => { // A: the runtime rejects `download`; A's abort must not later free a name it never owned. - const modelContext = installModelContext(document, { - rejectTool: "simplepdf_embed_download", - }); - const first = mountReady({ - webMCP: { enabled: true }, - logger: makeLogger(), - }); - await waitForTools(modelContext, TOOL_COUNT - 1); + const modelContext = installModelContext(document, { rejectTool: 'simplepdf_embed_download' }) + const first = mountReady({ webMCP: { enabled: true }, logger: makeLogger() }) + await waitForTools(modelContext, TOOL_COUNT - 1) // B: on an accepting context, takes `download` (the rest are reported as A's). - const accepting = installModelContext(document); - const second = mountReady({ - webMCP: { enabled: true }, - logger: makeLogger(), - }); - await waitForTools(accepting, 1); - expect(accepting.registered[0]?.name).toBe("simplepdf_embed_download"); + const accepting = installModelContext(document) + const second = mountReady({ webMCP: { enabled: true }, logger: makeLogger() }) + await waitForTools(accepting, 1) + expect(accepting.registered[0]?.name).toBe('simplepdf_embed_download') // A disposes: its names are freed for C, but B's `download` stays owned, so C is refused it. - first.embed.lifecycle.dispose(); - const logger = makeLogger(); - mountReady({ webMCP: { enabled: true }, logger }); - await waitForTools(accepting, TOOL_COUNT); - expect(logger.warn).toHaveBeenCalledWith("webmcp.tool_already_registered", { - tool: "simplepdf_embed_download", - }); - expect(logger.warn).toHaveBeenCalledTimes(1); - expect( - accepting.registered.filter( - (tool) => tool.name === "simplepdf_embed_download" - ) - ).toHaveLength(1); - second.embed.lifecycle.dispose(); - }); - - it("falls back to navigator.modelContext when the document exposes none", async () => { - const modelContext = installModelContext(navigator); - mountReady({ webMCP: { enabled: true } }); - await waitForTools(modelContext, TOOL_COUNT); - expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT); - }); - - it("reports an absent model context, never throws, and registers once a context appears", async () => { - const logger = makeLogger(); - const harness = makeHarness({ webMCP: { enabled: true }, logger }); - harness.markEditorReady(); - await vi.waitFor(() => - expect(logger.info).toHaveBeenCalledWith("webmcp.unavailable", { - reason: "no_model_context", - }) - ); - expect(logger.error).not.toHaveBeenCalled(); + first.embed.lifecycle.dispose() + const logger = makeLogger() + mountReady({ webMCP: { enabled: true }, logger }) + await waitForTools(accepting, TOOL_COUNT) + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_download' }) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(accepting.registered.filter((tool) => tool.name === 'simplepdf_embed_download')).toHaveLength(1) + second.embed.lifecycle.dispose() + }) + + it('falls back to navigator.modelContext when the document exposes none', async () => { + const modelContext = installModelContext(navigator) + mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + }) + + it('reports an absent model context, never throws, and registers once a context appears', async () => { + const logger = makeLogger() + const harness = makeHarness({ webMCP: { enabled: true }, logger }) + harness.markEditorReady() + await vi.waitFor(() => expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'no_model_context' })) + expect(logger.error).not.toHaveBeenCalled() // A context installed after a fast EDITOR_READY (an extension injected late) is // picked up on the next lifecycle transition. - const modelContext = installModelContext(document); - harness.markDocumentLoaded(); - await waitForTools(modelContext, TOOL_COUNT); - }); - - it("reports a model context without registerTool as invalid and keeps probing, so a placeholder filled in later still gets the tools", async () => { - Object.defineProperty(document, "modelContext", { - configurable: true, - value: {}, - }); - const logger = makeLogger(); - const harness = mountReady({ webMCP: { enabled: true }, logger }); + const modelContext = installModelContext(document) + harness.markDocumentLoaded() + await waitForTools(modelContext, TOOL_COUNT) + }) + + it('reports a model context without registerTool as invalid and keeps probing, so a placeholder filled in later still gets the tools', async () => { + Object.defineProperty(document, 'modelContext', { configurable: true, value: {} }) + const logger = makeLogger() + const harness = mountReady({ webMCP: { enabled: true }, logger }) await vi.waitFor(() => - expect(logger.info).toHaveBeenCalledWith("webmcp.unavailable", { - reason: "invalid_model_context", - }) - ); - - const modelContext = installModelContext(document); - harness.markDocumentLoaded(); - await waitForTools(modelContext, TOOL_COUNT); - }); - - it("keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name", async () => { - const modelContext = installModelContext(document, { - rejectTool: "simplepdf_embed_download", - }); - const logger = makeLogger(); - mountReady({ webMCP: { enabled: true }, logger }); - await waitForTools(modelContext, TOOL_COUNT - 1); - - expect(modelContext.registered.map((tool) => tool.name)).not.toContain( - "simplepdf_embed_download" - ); + expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), + ) + + const modelContext = installModelContext(document) + harness.markDocumentLoaded() + await waitForTools(modelContext, TOOL_COUNT) + }) + + it('keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name', async () => { + const modelContext = installModelContext(document, { rejectTool: 'simplepdf_embed_download' }) + const logger = makeLogger() + mountReady({ webMCP: { enabled: true }, logger }) + await waitForTools(modelContext, TOOL_COUNT - 1) + + expect(modelContext.registered.map((tool) => tool.name)).not.toContain('simplepdf_embed_download') await vi.waitFor(() => - expect(logger.error).toHaveBeenCalledWith("webmcp.register_tool_failed", { - tool: "simplepdf_embed_download", - message: "runtime rejected simplepdf_embed_download", - }) - ); - }); -}); + expect(logger.error).toHaveBeenCalledWith('webmcp.register_tool_failed', { + tool: 'simplepdf_embed_download', + message: 'runtime rejected simplepdf_embed_download', + }), + ) + }) +}) diff --git a/embed/tsup.config.ts b/embed/tsup.config.ts index 656dedd5..d86ffe95 100644 --- a/embed/tsup.config.ts +++ b/embed/tsup.config.ts @@ -19,7 +19,6 @@ export default defineConfig({ dts: true, treeshake: true, splitting: true, - sourcemap: true, clean: true, external: ['zod', '@tanstack/ai'], }) diff --git a/react/rollup.config.js b/react/rollup.config.js index c2b37d14..1e6b2506 100644 --- a/react/rollup.config.js +++ b/react/rollup.config.js @@ -24,7 +24,6 @@ export default { dir: 'dist', format: 'cjs', exports: 'named', - sourcemap: true, entryFileNames: '[name].cjs', chunkFileNames: 'chunks/[name]-[hash].cjs', }, @@ -32,7 +31,6 @@ export default { dir: 'dist', format: 'es', exports: 'named', - sourcemap: true, entryFileNames: '[name].esm.js', chunkFileNames: 'chunks/[name]-[hash].esm.js', }, diff --git a/react/tsconfig.json b/react/tsconfig.json index 2ff760f6..1181cfa3 100644 --- a/react/tsconfig.json +++ b/react/tsconfig.json @@ -4,7 +4,6 @@ "module": "esnext", "target": "es5", "lib": ["es6", "dom"], - "sourceMap": true, "allowJs": false, "jsx": "react", "declaration": true, From dcea4f7d780f54b99450b22c0e1fe045b2792907 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 20:18:34 +0200 Subject: [PATCH 03/10] chore: changeset measures the full tool set, the generator comment points at the generated tools --- .changeset/smaller-packages.md | 4 ++-- embed/scripts/generate.mjs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/smaller-packages.md b/.changeset/smaller-packages.md index 43e2427c..d3275c68 100644 --- a/.changeset/smaller-packages.md +++ b/.changeset/smaller-packages.md @@ -5,6 +5,6 @@ **Smaller installs and leaner agent requests.** -- The agentic tools no longer repeat each tool's description inside its input schema: every model request through the AI SDK or TanStack AI tools sends about 3.7 KB less, and the tool entries are about 28% smaller. -- The packages no longer ship source maps: `@simplepdf/embed` unpacks to 263 KB instead of 645 KB, `@simplepdf/react-embed-pdf` to 41 KB instead of 113 KB. +- The agentic tools no longer repeat each tool's description inside its input schema: every model request carrying all the AI SDK or TanStack AI tools sends about 4 KB less, and the agentic entry points are about 29% smaller. +- `@simplepdf/embed` and `@simplepdf/react-embed-pdf` no longer ship source maps: `@simplepdf/embed` unpacks to 263 KB instead of 645 KB, `@simplepdf/react-embed-pdf` to 41 KB instead of 113 KB. - The `/schemas` input schemas no longer carry the operation description at their root; read it from `SIMPLEPDF_TOOLS[name].description`, or from `OPERATIONS` in `@simplepdf/embed/protocol` for every operation including `loadDocument`. diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 16c1714a..d10c9508 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -436,8 +436,7 @@ schemaLines.push('') for (const op of contract.operations) { const stem = toPascal(op.request_type) - // The operation description lives on the tool (tools.ts); a root `.describe()` would - // ship it twice and send it twice to the model in every tool's JSON schema. + // The description lives on the tool (src/generated/tools.ts); repeating it here sends it to the model twice schemaLines.push(`export const ${stem}Input = ${zodForNode(op.input_schema, { withDescription: false })}`) // Value + type share the PascalCase name (the canonical zod idiom). schemaLines.push(`export type ${stem}Input = z.infer`) From b302e22d9aad7989fc5d3a7b5a72e8d19c661d96 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 20:47:01 +0200 Subject: [PATCH 04/10] feat: rename batchSetFieldValue to batchSetFieldValues and shorten its description --- .changeset/batch-set-field-value.md | 6 +++--- embed/README.md | 2 +- embed/embed-api.json | 12 ++++++------ embed/etc/index.api.md | 4 ++-- embed/etc/protocol.api.md | 12 ++++++------ embed/etc/schemas.api.md | 4 ++-- embed/etc/tools.api.md | 4 ++-- embed/src/bridge.ts | 2 +- embed/src/generated/contract.ts | 14 +++++++------- embed/src/generated/drift.ts | 2 +- embed/src/generated/method-names.ts | 2 +- embed/src/generated/schemas.ts | 4 ++-- embed/src/generated/tools.ts | 2 +- embed/src/generated/webmcp-tools.ts | 2 +- embed/src/tools.ts | 6 +++--- embed/src/types.ts | 6 +++--- embed/src/webmcp.ts | 2 +- embed/test/mount.test.ts | 2 +- react/README.md | 2 +- react/src/embed-pdf.tsx | 2 +- 20 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.changeset/batch-set-field-value.md b/.changeset/batch-set-field-value.md index a78d7603..277cb39d 100644 --- a/.changeset/batch-set-field-value.md +++ b/.changeset/batch-set-field-value.md @@ -3,9 +3,9 @@ "@simplepdf/react-embed-pdf": minor --- -**Fill several fields in one call: `batchSetFieldValue` sets every value at once, or none of them.** +**Fill several fields in one call: `batchSetFieldValues` sets every value at once, or none of them.** -- `batchSetFieldValue({ updates: [{ fieldId, value }] })` applies every update in one step, reverted by a single undo in the editor, and resolves with no data. +- `batchSetFieldValues({ updates: [{ fieldId, value }] })` applies every update in one step, reverted by a single undo in the editor, and resolves with no data. - If any update is rejected (an unknown field, an invalid option, a read-only field, an unreadable signature URL, the same field twice), nothing changes: the result fails with `bad_request:batch_validation_failed` and `error.details.failures` lists every rejected update with its `fieldId`, `code` and `message` (typed as `BatchValidationFailedDetails`). - Values follow the `setFieldValue` rules and are never typed out. -- Available everywhere the editor operations are: `embed.actions.batchSetFieldValue`, `useEmbed().actions.batchSetFieldValue`, the `batchSetFieldValue` agentic tool on every tool subpath, and the `simplepdf_embed_batch_set_field_value` WebMCP tool. +- Available everywhere the editor operations are: `embed.actions.batchSetFieldValues`, `useEmbed().actions.batchSetFieldValues`, the `batchSetFieldValues` agentic tool on every tool subpath, and the `simplepdf_embed_batch_set_field_values` WebMCP tool. diff --git a/embed/README.md b/embed/README.md index 61c3270f..462a3664 100644 --- a/embed/README.md +++ b/embed/README.md @@ -184,7 +184,7 @@ await embed.actions.rotatePage({ page: 1 }) await embed.actions.download() ``` -Full set: `batchSetFieldValue`, `createField`, `deleteFields`, `deletePages`, `detectFields`, `download`, `focusField`, `getAnnotatedPage`, `getDocumentContent`, `getFields`, `goTo`, `loadDocument`, `movePage`, `rotatePage`, `selectTool`, `setFieldValue`, `submit`. +Full set: `batchSetFieldValues`, `createField`, `deleteFields`, `deletePages`, `detectFields`, `download`, `focusField`, `getAnnotatedPage`, `getDocumentContent`, `getFields`, `goTo`, `loadDocument`, `movePage`, `rotatePage`, `selectTool`, `setFieldValue`, `submit`. **"Fill and read this document for me"** is just these operations in sequence, exactly what the agentic tools expose to a model: diff --git a/embed/embed-api.json b/embed/embed-api.json index acec3107..67cd7a41 100644 --- a/embed/embed-api.json +++ b/embed/embed-api.json @@ -77,8 +77,8 @@ }, "operations": [ { - "request_type": "BATCH_SET_FIELD_VALUE", - "description": "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", + "request_type": "BATCH_SET_FIELD_VALUES", + "description": "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -109,7 +109,7 @@ } }, "required": ["updates"], - "description": "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data." + "description": "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data." }, "output_schema": { "type": "null" @@ -120,8 +120,8 @@ "bad_request:no_document_loaded" ], "tool": { - "name": "simplepdf_embed_batch_set_field_value", - "description": "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", + "name": "simplepdf_embed_batch_set_field_values", + "description": "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -1164,7 +1164,7 @@ "code": { "type": "string", "const": "bad_request:batch_validation_failed", - "description": "At least one update of batch_set_field_value was rejected, so no field changed. details.failures lists every rejected update with its field_id and the set_field_value code and message explaining why." + "description": "At least one update of batch_set_field_values was rejected, so no field changed. details.failures lists every rejected update with its field_id and the set_field_value code and message explaining why." }, "message": { "type": "string" diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index 3afe6493..3b7195b0 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -5,7 +5,7 @@ ```ts // @public (undocumented) -export type BatchSetFieldValueInput = { +export type BatchSetFieldValuesInput = { updates: Array<{ fieldId: string; value: string | null; @@ -280,7 +280,7 @@ export type GoToInput = { // @public (undocumented) export type IframeActions = { - batchSetFieldValue: (input: BatchSetFieldValueInput) => Promise; + batchSetFieldValues: (input: BatchSetFieldValuesInput) => Promise; createField: (input: CreateFieldInput) => Promise>; deleteFields: (input?: DeleteFieldsInput) => Promise>; deletePages: (input: DeletePagesInput) => Promise; diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index f065045b..a89cb18d 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -27,10 +27,10 @@ export const LOCALES: readonly ["fr", "en", "it", "de", "pt", "es", "ja", "nl"]; // @public (undocumented) export const OPERATIONS: readonly [{ - readonly request_type: "BATCH_SET_FIELD_VALUE"; - readonly wire_type: "BATCH_SET_FIELD_VALUE"; - readonly method: "batchSetFieldValue"; - readonly description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data."; + readonly request_type: "BATCH_SET_FIELD_VALUES"; + readonly wire_type: "BATCH_SET_FIELD_VALUES"; + readonly method: "batchSetFieldValues"; + readonly description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data."; readonly error_codes: readonly ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -192,13 +192,13 @@ export const OVERLAY_TOOL_TYPES: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHEC export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number]; // @public (undocumented) -export const REQUEST_TYPES: ("BATCH_SET_FIELD_VALUE" | "CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; +export const REQUEST_TYPES: ("BATCH_SET_FIELD_VALUES" | "CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; // @public (undocumented) export type RequestType = (typeof OPERATIONS)[number]["request_type"]; // @public (undocumented) -export const WIRE_TYPES: ("BATCH_SET_FIELD_VALUE" | "CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; +export const WIRE_TYPES: ("BATCH_SET_FIELD_VALUES" | "CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; // @public (undocumented) export type WireType = (typeof OPERATIONS)[number]["wire_type"]; diff --git a/embed/etc/schemas.api.md b/embed/etc/schemas.api.md index 1d475783..6becd80f 100644 --- a/embed/etc/schemas.api.md +++ b/embed/etc/schemas.api.md @@ -7,7 +7,7 @@ import { z } from 'zod'; // @public (undocumented) -export const BatchSetFieldValueInput: z.ZodObject<{ +export const BatchSetFieldValuesInput: z.ZodObject<{ updates: z.ZodArray; @@ -15,7 +15,7 @@ export const BatchSetFieldValueInput: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type BatchSetFieldValueInput = z.infer; +export type BatchSetFieldValuesInput = z.infer; // @public (undocumented) export const CreateFieldInput: z.ZodObject<{ diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md index 01f0efbd..02231666 100644 --- a/embed/etc/tools.api.md +++ b/embed/etc/tools.api.md @@ -18,8 +18,8 @@ export const routeToolCall: (actions: IframeActions, toolName: SimplePDFToolName // @public (undocumented) export const SIMPLEPDF_TOOLS: { - readonly batchSetFieldValue: { - readonly description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data."; + readonly batchSetFieldValues: { + readonly description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data."; readonly inputSchema: zod.ZodObject<{ updates: zod.ZodArray sendRequest('BATCH_SET_FIELD_VALUE', input), + batchSetFieldValues: (input) => sendRequest('BATCH_SET_FIELD_VALUES', input), createField: (input) => sendRequest('CREATE_FIELD', input), // All-optional input: deleteFields() with no args clears every field. deleteFields: (input) => sendRequest('DELETE_FIELDS', input ?? {}), diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index 6e2bbd4c..d6dda56c 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -17,8 +17,8 @@ export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number] export const EXTRACTION_MODES = ["auto", "ocr"] as const export type ExtractionMode = (typeof EXTRACTION_MODES)[number] -export type BatchSetFieldValueInput = { updates: Array<{ fieldId: string; value: string | null }> } -export type BatchSetFieldValueOutput = null +export type BatchSetFieldValuesInput = { updates: Array<{ fieldId: string; value: string | null }> } +export type BatchSetFieldValuesOutput = null export type CreateFieldInput = { type: OverlayToolType; x: number; y: number; width: number; height: number; page: number; value?: string } export type CreateFieldOutput = { fieldId: string } export type DeleteFieldsInput = { fieldIds?: string[]; page?: number } @@ -69,14 +69,14 @@ export type SubmissionSentPayload = { document_id: string; submission_id: string export const OPERATIONS = [ { - request_type: "BATCH_SET_FIELD_VALUE", - wire_type: "BATCH_SET_FIELD_VALUE", - method: "batchSetFieldValue", - description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", + request_type: "BATCH_SET_FIELD_VALUES", + wire_type: "BATCH_SET_FIELD_VALUES", + method: "batchSetFieldValues", + description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", error_codes: ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: false, - } /* BatchSetFieldValue */, + } /* BatchSetFieldValues */, { request_type: "CREATE_FIELD", wire_type: "CREATE_FIELD", diff --git a/embed/src/generated/drift.ts b/embed/src/generated/drift.ts index 43b442c4..674c0034 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -14,7 +14,7 @@ export type DriftGuards = [ AssertTrue>, AssertTrue>, AssertTrue>, - AssertTrue>, + AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, diff --git a/embed/src/generated/method-names.ts b/embed/src/generated/method-names.ts index 0bf21b5b..596c7cef 100644 --- a/embed/src/generated/method-names.ts +++ b/embed/src/generated/method-names.ts @@ -2,4 +2,4 @@ // The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list // without pulling the operations table into the zero-dep root; contract.ts derives // MethodName from this list. -export const METHOD_NAMES = ["batchSetFieldValue", "createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getAnnotatedPage", "getDocumentContent", "getFields", "goTo", "loadDocument", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const +export const METHOD_NAMES = ["batchSetFieldValues", "createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getAnnotatedPage", "getDocumentContent", "getFields", "goTo", "loadDocument", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const diff --git a/embed/src/generated/schemas.ts b/embed/src/generated/schemas.ts index c62c2fc9..ebd7b65c 100644 --- a/embed/src/generated/schemas.ts +++ b/embed/src/generated/schemas.ts @@ -1,13 +1,13 @@ // AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. import { z } from 'zod' -export const BatchSetFieldValueInput = z.object({ +export const BatchSetFieldValuesInput = z.object({ updates: z.array(z.object({ fieldId: z.string().describe("ID of the field to update."), value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), })).describe("The fields to set, at least one, each field at most once. Fields not listed keep their value."), }) -export type BatchSetFieldValueInput = z.infer +export type BatchSetFieldValuesInput = z.infer export const CreateFieldInput = z.object({ type: z.enum(["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]).describe("Field type to create."), x: z.number().describe("Field x position, in PDF points."), diff --git a/embed/src/generated/tools.ts b/embed/src/generated/tools.ts index 037f74bd..a62f5862 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -4,7 +4,7 @@ import * as Schemas from './schemas' // The agentic tool registry. Each tool name is the camelCase operation name; // load_document is excluded here (a host/setup action; the WebMCP surface registers it). export const TOOL_DEFINITIONS = { - batchSetFieldValue: { description: "Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.", inputSchema: Schemas.BatchSetFieldValueInput }, + batchSetFieldValues: { description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", inputSchema: Schemas.BatchSetFieldValuesInput }, createField: { description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", inputSchema: Schemas.CreateFieldInput }, deleteFields: { description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", inputSchema: Schemas.DeleteFieldsInput }, deletePages: { description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.DeletePagesInput }, diff --git a/embed/src/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts index 5af96e5d..1384547f 100644 --- a/embed/src/generated/webmcp-tools.ts +++ b/embed/src/generated/webmcp-tools.ts @@ -25,7 +25,7 @@ export type WebMCPToolRecord = { } export const WEBMCP_TOOLS = { - batchSetFieldValue: {"name":"simplepdf_embed_batch_set_field_value","description":"Set the values of several existing fields in one call, addressed by their ids (from the field list). Every update is validated before anything changes: all of them are applied at once, as a single undo step, or none is and the call fails with bad_request:batch_validation_failed, whose details.failures lists every rejected update (field_id, code, message) so the whole batch can be corrected and sent again. A signature URL is fetched only once every other update is valid. Values follow set_field_value and are never typed out. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUE"}, + batchSetFieldValues: {"name":"simplepdf_embed_batch_set_field_values","description":"Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUES"}, createField: {"name":"simplepdf_embed_create_field","description":"Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, deleteFields: {"name":"simplepdf_embed_delete_fields","description":"Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, deletePages: {"name":"simplepdf_embed_delete_pages","description":"Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, diff --git a/embed/src/tools.ts b/embed/src/tools.ts index 19c7be30..fc2eb598 100644 --- a/embed/src/tools.ts +++ b/embed/src/tools.ts @@ -7,7 +7,7 @@ import type { z } from 'zod' import { TOOL_DEFINITIONS, type SimplePDFToolName } from './generated/tools' import { - BatchSetFieldValueInput, + BatchSetFieldValuesInput, CreateFieldInput, DeleteFieldsInput, DeletePagesInput, @@ -59,8 +59,8 @@ export const routeToolCall = ( input: unknown, ): Promise> => { switch (toolName) { - case 'batchSetFieldValue': - return dispatch(BatchSetFieldValueInput, input, (value) => actions.batchSetFieldValue(value)) + case 'batchSetFieldValues': + return dispatch(BatchSetFieldValuesInput, input, (value) => actions.batchSetFieldValues(value)) case 'createField': return dispatch(CreateFieldInput, input, (value) => actions.createField(value)) case 'deleteFields': diff --git a/embed/src/types.ts b/embed/src/types.ts index 3508a6d5..afc4e310 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -4,7 +4,7 @@ // the single source of truth; nothing in this file restates a wire shape. import type { - BatchSetFieldValueInput, + BatchSetFieldValuesInput, CreateFieldInput, CreateFieldOutput, DeleteFieldsInput, @@ -34,7 +34,7 @@ import type { } from './generated/contract' export type { - BatchSetFieldValueInput, + BatchSetFieldValuesInput, BatchValidationFailedDetails, CreateFieldInput, CreateFieldOutput, @@ -139,7 +139,7 @@ export type EditorEventMap = { [TEvent in EditorEvent as TEvent['type']]: TEvent // throws. Drift-guarded against the generated operation set in ./generated/drift.ts // (a new operation fails the build until added here). export type IframeActions = { - batchSetFieldValue: (input: BatchSetFieldValueInput) => Promise + batchSetFieldValues: (input: BatchSetFieldValuesInput) => Promise createField: (input: CreateFieldInput) => Promise> deleteFields: (input?: DeleteFieldsInput) => Promise> deletePages: (input: DeletePagesInput) => Promise diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 1c40f0dd..156b1fd6 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -72,7 +72,7 @@ const toCallToolResult = (wireType: WireType, result: BridgeResult): Ca switch (wireType) { case 'GET_ANNOTATED_PAGE': return toAnnotatedPageToolResult(result) - case 'BATCH_SET_FIELD_VALUE': + case 'BATCH_SET_FIELD_VALUES': case 'CREATE_FIELD': case 'DELETE_FIELDS': case 'DELETE_PAGES': diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index c8bdf5ae..7c3cc9a5 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -414,6 +414,6 @@ describe(createEmbed.name, () => { document.body.innerHTML = '
' const misspelled: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, exclude: ['sumbit'] } } // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(misspelled)).toThrow(/webMCP\.exclude names no tool: sumbit \(known: batchSetFieldValue, createField/) + expect(() => createEmbed(misspelled)).toThrow(/webMCP\.exclude names no tool: sumbit \(known: batchSetFieldValues, createField/) }) }) diff --git a/react/README.md b/react/README.md index 274175f3..e3a351a0 100644 --- a/react/README.md +++ b/react/README.md @@ -155,7 +155,7 @@ Actions are camelCase (the editor's snake_case wire is transformed for you). `us | `actions.deleteFields({ fieldIds?, page? })` | Delete fields by id or page, or all fields if both are omitted | | `actions.getDocumentContent({ extractionMode })` | Extract document content (`'auto'` or `'ocr'`) | | `actions.setFieldValue({ fieldId, value })` | Set a field's value | -| `actions.batchSetFieldValue({ updates })` | Set several fields at once (`updates: [{ fieldId, value }]`): all apply, or none do | +| `actions.batchSetFieldValues({ updates })` | Set several fields at once (`updates: [{ fieldId, value }]`): all apply, or none do | | `actions.submit({ downloadCopy })` | Submit the document | …plus `createField`, `getFields`, `getAnnotatedPage`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, and `loadDocument`. All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index b1613a8a..f4ee7bac 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -408,7 +408,7 @@ export const useEmbed = (): { const actions = React.useMemo( () => ({ - batchSetFieldValue: (input) => embedRef.current?.batchSetFieldValue(input) ?? notMounted(), + batchSetFieldValues: (input) => embedRef.current?.batchSetFieldValues(input) ?? notMounted(), createField: (input) => embedRef.current?.createField(input) ?? notMounted(), deleteFields: (input) => embedRef.current?.deleteFields(input) ?? notMounted(), deletePages: (input) => embedRef.current?.deletePages(input) ?? notMounted(), From d6b7e70ce77e0fbbd333ad0de10226d5bc134f32 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 21:56:23 +0200 Subject: [PATCH 05/10] docs: align the batchSetFieldValues row in the React README --- react/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/README.md b/react/README.md index e3a351a0..cccdbf70 100644 --- a/react/README.md +++ b/react/README.md @@ -155,7 +155,7 @@ Actions are camelCase (the editor's snake_case wire is transformed for you). `us | `actions.deleteFields({ fieldIds?, page? })` | Delete fields by id or page, or all fields if both are omitted | | `actions.getDocumentContent({ extractionMode })` | Extract document content (`'auto'` or `'ocr'`) | | `actions.setFieldValue({ fieldId, value })` | Set a field's value | -| `actions.batchSetFieldValues({ updates })` | Set several fields at once (`updates: [{ fieldId, value }]`): all apply, or none do | +| `actions.batchSetFieldValues({ updates })` | Set several fields at once (`updates: [{ fieldId, value }]`): all apply, or none do | | `actions.submit({ downloadCopy })` | Submit the document | …plus `createField`, `getFields`, `getAnnotatedPage`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, and `loadDocument`. All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. From 237fdc3edfa3cb9b22f8e76964d12c1819a23801 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 23:49:35 +0200 Subject: [PATCH 06/10] feat: shorter tool descriptions: value rules live on the value parameter, no permission notes, get_annotated_page names the WebMCP image block --- .changeset/smaller-packages.md | 1 + embed/embed-api.json | 68 ++++++++++++++--------------- embed/etc/protocol.api.md | 20 ++++----- embed/etc/tools.api.md | 20 ++++----- embed/src/generated/contract.ts | 20 ++++----- embed/src/generated/schemas.ts | 4 +- embed/src/generated/tools.ts | 20 ++++----- embed/src/generated/webmcp-tools.ts | 20 ++++----- embed/test/webmcp.test.ts | 2 +- 9 files changed, 88 insertions(+), 87 deletions(-) diff --git a/.changeset/smaller-packages.md b/.changeset/smaller-packages.md index d3275c68..76d3bb82 100644 --- a/.changeset/smaller-packages.md +++ b/.changeset/smaller-packages.md @@ -6,5 +6,6 @@ **Smaller installs and leaner agent requests.** - The agentic tools no longer repeat each tool's description inside its input schema: every model request carrying all the AI SDK or TanStack AI tools sends about 4 KB less, and the agentic entry points are about 29% smaller. +- Tool descriptions are about 17% shorter (697 fewer characters across the 17 tools): `setFieldValue` and `batchSetFieldValues` leave the value rules to their `value` parameter, permission notes are gone (a refused call answers with its error code), and `getAnnotatedPage` states that the WebMCP tool returns the render as an image block and the rest of the result as a text block. - `@simplepdf/embed` and `@simplepdf/react-embed-pdf` no longer ship source maps: `@simplepdf/embed` unpacks to 263 KB instead of 645 KB, `@simplepdf/react-embed-pdf` to 41 KB instead of 113 KB. - The `/schemas` input schemas no longer carry the operation description at their root; read it from `SIMPLEPDF_TOOLS[name].description`, or from `OPERATIONS` in `@simplepdf/embed/protocol` for every operation including `loadDocument`. diff --git a/embed/embed-api.json b/embed/embed-api.json index 67cd7a41..32b8aeb3 100644 --- a/embed/embed-api.json +++ b/embed/embed-api.json @@ -78,7 +78,7 @@ "operations": [ { "request_type": "BATCH_SET_FIELD_VALUES", - "description": "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", + "description": "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -100,7 +100,7 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." } }, "required": ["field_id", "value"] @@ -109,7 +109,7 @@ } }, "required": ["updates"], - "description": "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data." + "description": "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data." }, "output_schema": { "type": "null" @@ -121,7 +121,7 @@ ], "tool": { "name": "simplepdf_embed_batch_set_field_values", - "description": "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", + "description": "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -142,7 +142,7 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." } }, "required": ["field_id", "value"] @@ -161,7 +161,7 @@ }, { "request_type": "CREATE_FIELD", - "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", + "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", "input_schema": { "type": "object", "properties": { @@ -196,7 +196,7 @@ } }, "required": ["type", "x", "y", "width", "height", "page"], - "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled." + "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field." }, "output_schema": { "type": "object", @@ -220,7 +220,7 @@ ], "tool": { "name": "simplepdf_embed_create_field", - "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", + "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", "input_schema": { "type": "object", "properties": { @@ -264,7 +264,7 @@ }, { "request_type": "DELETE_FIELDS", - "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", + "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", "input_schema": { "type": "object", "properties": { @@ -280,7 +280,7 @@ "type": "integer" } }, - "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled." + "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive." }, "output_schema": { "type": "object", @@ -301,7 +301,7 @@ ], "tool": { "name": "simplepdf_embed_delete_fields", - "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", + "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", "input_schema": { "type": "object", "properties": { @@ -325,7 +325,7 @@ }, { "request_type": "DELETE_PAGES", - "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", + "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -338,7 +338,7 @@ } }, "required": ["pages"], - "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled." + "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive." }, "output_schema": { "type": "null" @@ -352,7 +352,7 @@ ], "tool": { "name": "simplepdf_embed_delete_pages", - "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", + "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -373,11 +373,11 @@ }, { "request_type": "DETECT_FIELDS", - "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", + "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", "input_schema": { "type": "object", "properties": {}, - "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled." + "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }." }, "output_schema": { "type": "object", @@ -394,7 +394,7 @@ ], "tool": { "name": "simplepdf_embed_detect_fields", - "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", + "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", "input_schema": { "type": "object", "properties": {} @@ -434,7 +434,7 @@ }, { "request_type": "FOCUS_FIELD", - "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", + "description": "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", "input_schema": { "type": "object", "properties": { @@ -444,7 +444,7 @@ } }, "required": ["field_id"], - "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next." + "description": "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next." }, "output_schema": { "type": "object", @@ -472,7 +472,7 @@ ], "tool": { "name": "simplepdf_embed_focus_field", - "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", + "description": "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", "input_schema": { "type": "object", "properties": { @@ -490,7 +490,7 @@ }, { "request_type": "GET_ANNOTATED_PAGE", - "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", + "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", "input_schema": { "type": "object", "properties": { @@ -500,7 +500,7 @@ } }, "required": ["page"], - "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant." + "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block." }, "output_schema": { "type": "object", @@ -544,7 +544,7 @@ ], "tool": { "name": "simplepdf_embed_get_annotated_page", - "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", + "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", "input_schema": { "type": "object", "properties": { @@ -819,7 +819,7 @@ }, { "request_type": "MOVE_PAGE", - "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", + "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -833,7 +833,7 @@ } }, "required": ["from_page", "to_page"], - "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled." + "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive." }, "output_schema": { "type": "null" @@ -847,7 +847,7 @@ ], "tool": { "name": "simplepdf_embed_move_page", - "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", + "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -869,7 +869,7 @@ }, { "request_type": "ROTATE_PAGE", - "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", + "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -879,7 +879,7 @@ } }, "required": ["page"], - "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled." + "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive." }, "output_schema": { "type": "null" @@ -893,7 +893,7 @@ ], "tool": { "name": "simplepdf_embed_rotate_page", - "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", + "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -978,7 +978,7 @@ }, { "request_type": "SET_FIELD_VALUE", - "description": "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.", + "description": "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -999,11 +999,11 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." } }, "required": ["field_id", "value"], - "description": "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data." + "description": "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data." }, "output_schema": { "type": "null" @@ -1017,7 +1017,7 @@ ], "tool": { "name": "simplepdf_embed_set_field_value", - "description": "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.", + "description": "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -1038,7 +1038,7 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." } }, "required": ["field_id", "value"] diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index a89cb18d..7cf19d5f 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -30,7 +30,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "BATCH_SET_FIELD_VALUES"; readonly wire_type: "BATCH_SET_FIELD_VALUES"; readonly method: "batchSetFieldValues"; - readonly description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data."; + readonly description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data."; readonly error_codes: readonly ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -38,7 +38,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "CREATE_FIELD"; readonly wire_type: "CREATE_FIELD"; readonly method: "createField"; - readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; + readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -46,7 +46,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "DELETE_FIELDS"; readonly wire_type: "DELETE_FIELDS"; readonly method: "deleteFields"; - readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled."; + readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -54,7 +54,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "DELETE_PAGES"; readonly wire_type: "DELETE_PAGES"; readonly method: "deletePages"; - readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled."; + readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -62,7 +62,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "DETECT_FIELDS"; readonly wire_type: "DETECT_FIELDS"; readonly method: "detectFields"; - readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled."; + readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -78,7 +78,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "FOCUS_FIELD"; readonly wire_type: "FOCUS_FIELD"; readonly method: "focusField"; - readonly description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next."; + readonly description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next."; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -86,7 +86,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_ANNOTATED_PAGE"; readonly wire_type: "GET_ANNOTATED_PAGE"; readonly method: "getAnnotatedPage"; - readonly description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant."; + readonly description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block."; readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -126,7 +126,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "MOVE_PAGE"; readonly wire_type: "MOVE_PAGE"; readonly method: "movePage"; - readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled."; + readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -134,7 +134,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "ROTATE_PAGE"; readonly wire_type: "ROTATE_PAGE"; readonly method: "rotatePage"; - readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled."; + readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -150,7 +150,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "SET_FIELD_VALUE"; readonly wire_type: "SET_FIELD_VALUE"; readonly method: "setFieldValue"; - readonly description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data."; + readonly description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data."; readonly error_codes: readonly ["bad_request:field_not_found", "bad_request:invalid_signature_url", "bad_request:invalid_value", "bad_request:read_only", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: false; diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md index 02231666..5f06cc94 100644 --- a/embed/etc/tools.api.md +++ b/embed/etc/tools.api.md @@ -19,7 +19,7 @@ export const routeToolCall: (actions: IframeActions, toolName: SimplePDFToolName // @public (undocumented) export const SIMPLEPDF_TOOLS: { readonly batchSetFieldValues: { - readonly description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data."; + readonly description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data."; readonly inputSchema: zod.ZodObject<{ updates: zod.ZodArray; }; readonly createField: { - readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; + readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field."; readonly inputSchema: zod.ZodObject<{ type: zod.ZodEnum<{ TEXT: "TEXT"; @@ -46,20 +46,20 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly deleteFields: { - readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled."; + readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive."; readonly inputSchema: zod.ZodObject<{ fieldIds: zod.ZodOptional>; page: zod.ZodOptional; }, zod_v4_core.$strip>; }; readonly deletePages: { - readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled."; + readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive."; readonly inputSchema: zod.ZodObject<{ pages: zod.ZodArray; }, zod_v4_core.$strip>; }; readonly detectFields: { - readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled."; + readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }."; readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; }; readonly download: { @@ -67,13 +67,13 @@ export const SIMPLEPDF_TOOLS: { readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; }; readonly focusField: { - readonly description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next."; + readonly description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next."; readonly inputSchema: zod.ZodObject<{ fieldId: zod.ZodString; }, zod_v4_core.$strip>; }; readonly getAnnotatedPage: { - readonly description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant."; + readonly description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block."; readonly inputSchema: zod.ZodObject<{ page: zod.ZodNumber; }, zod_v4_core.$strip>; @@ -98,14 +98,14 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly movePage: { - readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled."; + readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive."; readonly inputSchema: zod.ZodObject<{ fromPage: zod.ZodNumber; toPage: zod.ZodNumber; }, zod_v4_core.$strip>; }; readonly rotatePage: { - readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled."; + readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive."; readonly inputSchema: zod.ZodObject<{ page: zod.ZodNumber; }, zod_v4_core.$strip>; @@ -123,7 +123,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly setFieldValue: { - readonly description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data."; + readonly description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data."; readonly inputSchema: zod.ZodObject<{ animate: zod.ZodOptional; fieldId: zod.ZodString; diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index d6dda56c..12011eed 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -72,7 +72,7 @@ export const OPERATIONS = [ request_type: "BATCH_SET_FIELD_VALUES", wire_type: "BATCH_SET_FIELD_VALUES", method: "batchSetFieldValues", - description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", + description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", error_codes: ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: false, @@ -81,7 +81,7 @@ export const OPERATIONS = [ request_type: "CREATE_FIELD", wire_type: "CREATE_FIELD", method: "createField", - description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", + description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"] as const, is_agentic_tool: true, has_output: true, @@ -90,7 +90,7 @@ export const OPERATIONS = [ request_type: "DELETE_FIELDS", wire_type: "DELETE_FIELDS", method: "deleteFields", - description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", + description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -99,7 +99,7 @@ export const OPERATIONS = [ request_type: "DELETE_PAGES", wire_type: "DELETE_PAGES", method: "deletePages", - description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", + description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -108,7 +108,7 @@ export const OPERATIONS = [ request_type: "DETECT_FIELDS", wire_type: "DETECT_FIELDS", method: "detectFields", - description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", + description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -126,7 +126,7 @@ export const OPERATIONS = [ request_type: "FOCUS_FIELD", wire_type: "FOCUS_FIELD", method: "focusField", - description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", + description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -135,7 +135,7 @@ export const OPERATIONS = [ request_type: "GET_ANNOTATED_PAGE", wire_type: "GET_ANNOTATED_PAGE", method: "getAnnotatedPage", - description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", + description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", error_codes: ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"] as const, is_agentic_tool: true, has_output: true, @@ -180,7 +180,7 @@ export const OPERATIONS = [ request_type: "MOVE_PAGE", wire_type: "MOVE_PAGE", method: "movePage", - description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", + description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -189,7 +189,7 @@ export const OPERATIONS = [ request_type: "ROTATE_PAGE", wire_type: "ROTATE_PAGE", method: "rotatePage", - description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", + description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -207,7 +207,7 @@ export const OPERATIONS = [ request_type: "SET_FIELD_VALUE", wire_type: "SET_FIELD_VALUE", method: "setFieldValue", - description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.", + description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", error_codes: ["bad_request:field_not_found", "bad_request:invalid_signature_url", "bad_request:invalid_value", "bad_request:read_only", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: false, diff --git a/embed/src/generated/schemas.ts b/embed/src/generated/schemas.ts index ebd7b65c..5078c758 100644 --- a/embed/src/generated/schemas.ts +++ b/embed/src/generated/schemas.ts @@ -4,7 +4,7 @@ import { z } from 'zod' export const BatchSetFieldValuesInput = z.object({ updates: z.array(z.object({ fieldId: z.string().describe("ID of the field to update."), - value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), + value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), })).describe("The fields to set, at least one, each field at most once. Fields not listed keep their value."), }) export type BatchSetFieldValuesInput = z.infer @@ -71,7 +71,7 @@ export type SelectToolInput = z.infer export const SetFieldValueInput = z.object({ animate: z.boolean().describe("Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.").optional(), fieldId: z.string().describe("ID of the field to update."), - value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), + value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), }) export type SetFieldValueInput = z.infer export const SubmitInput = z.object({ diff --git a/embed/src/generated/tools.ts b/embed/src/generated/tools.ts index a62f5862..5b6bb3a8 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -4,21 +4,21 @@ import * as Schemas from './schemas' // The agentic tool registry. Each tool name is the camelCase operation name; // load_document is excluded here (a host/setup action; the WebMCP surface registers it). export const TOOL_DEFINITIONS = { - batchSetFieldValues: { description: "Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.", inputSchema: Schemas.BatchSetFieldValuesInput }, - createField: { description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", inputSchema: Schemas.CreateFieldInput }, - deleteFields: { description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", inputSchema: Schemas.DeleteFieldsInput }, - deletePages: { description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.DeletePagesInput }, - detectFields: { description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", inputSchema: Schemas.DetectFieldsInput }, + batchSetFieldValues: { description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", inputSchema: Schemas.BatchSetFieldValuesInput }, + createField: { description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", inputSchema: Schemas.CreateFieldInput }, + deleteFields: { description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", inputSchema: Schemas.DeleteFieldsInput }, + deletePages: { description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", inputSchema: Schemas.DeletePagesInput }, + detectFields: { description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", inputSchema: Schemas.DetectFieldsInput }, download: { description: "Generate and download the current document as a PDF. Returns no data.", inputSchema: Schemas.DownloadInput }, - focusField: { description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", inputSchema: Schemas.FocusFieldInput }, - getAnnotatedPage: { description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", inputSchema: Schemas.GetAnnotatedPageInput }, + focusField: { description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", inputSchema: Schemas.FocusFieldInput }, + getAnnotatedPage: { description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", inputSchema: Schemas.GetAnnotatedPageInput }, getDocumentContent: { description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", inputSchema: Schemas.GetDocumentContentInput }, getFields: { description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", inputSchema: Schemas.GetFieldsInput }, goTo: { description: "Scroll the editor to a specific 1-based page. Returns no data.", inputSchema: Schemas.GoToInput }, - movePage: { description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.MovePageInput }, - rotatePage: { description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.RotatePageInput }, + movePage: { description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", inputSchema: Schemas.MovePageInput }, + rotatePage: { description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", inputSchema: Schemas.RotatePageInput }, selectTool: { description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.", inputSchema: Schemas.SelectToolInput }, - setFieldValue: { description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.", inputSchema: Schemas.SetFieldValueInput }, + setFieldValue: { description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", inputSchema: Schemas.SetFieldValueInput }, submit: { description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", inputSchema: Schemas.SubmitInput }, } as const diff --git a/embed/src/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts index 1384547f..c62271c8 100644 --- a/embed/src/generated/webmcp-tools.ts +++ b/embed/src/generated/webmcp-tools.ts @@ -25,21 +25,21 @@ export type WebMCPToolRecord = { } export const WEBMCP_TOOLS = { - batchSetFieldValues: {"name":"simplepdf_embed_batch_set_field_values","description":"Set several existing fields in one call, addressed by their ids (from the field list). Either every update applies, as one undo step, or none does and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejected update (field_id, code, message). Values follow set_field_value and are never typed out. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUES"}, - createField: {"name":"simplepdf_embed_create_field","description":"Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, - deleteFields: {"name":"simplepdf_embed_delete_fields","description":"Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, - deletePages: {"name":"simplepdf_embed_delete_pages","description":"Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, - detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DETECT_FIELDS"}, + batchSetFieldValues: {"name":"simplepdf_embed_batch_set_field_values","description":"Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUES"}, + createField: {"name":"simplepdf_embed_create_field","description":"Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, + deleteFields: {"name":"simplepdf_embed_delete_fields","description":"Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, + deletePages: {"name":"simplepdf_embed_delete_pages","description":"Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, + detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DETECT_FIELDS"}, download: {"name":"simplepdf_embed_download","description":"Generate and download the current document as a PDF. Returns no data.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DOWNLOAD"}, - focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false},"wireType":"FOCUS_FIELD"}, - getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_ANNOTATED_PAGE"}, + focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false},"wireType":"FOCUS_FIELD"}, + getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_ANNOTATED_PAGE"}, getDocumentContent: {"name":"simplepdf_embed_get_document_content","description":"Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.","inputSchema":{"type":"object","properties":{"extraction_mode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","enum":["auto","ocr"],"type":"string"}}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_DOCUMENT_CONTENT"}, getFields: {"name":"simplepdf_embed_get_fields","description":"List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_FIELDS"}, goTo: {"name":"simplepdf_embed_go_to","description":"Scroll the editor to a specific 1-based page. Returns no data.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to navigate to.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":false},"wireType":"GO_TO"}, loadDocument: {"name":"simplepdf_embed_load_document","description":"Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Replies once the document and its fields are ready, so the next operation can act on it, and DOCUMENT_LOADED follows the reply; the URL already open is left as is and answers once it settles; a document that cannot be loaded or prepared answers bad_request:failed_to_load_document. Returns no data.","inputSchema":{"type":"object","properties":{"data_url":{"description":"The document to load: a data URL, or an http(s) URL the editor fetches.","type":"string"},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["data_url"]},"annotations":{"destructiveHint":true,"openWorldHint":true},"wireType":"LOAD_DOCUMENT"}, - movePage: {"name":"simplepdf_embed_move_page","description":"Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true},"wireType":"MOVE_PAGE"}, - rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate 90 degrees clockwise.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true},"wireType":"ROTATE_PAGE"}, + movePage: {"name":"simplepdf_embed_move_page","description":"Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true},"wireType":"MOVE_PAGE"}, + rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate 90 degrees clockwise.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true},"wireType":"ROTATE_PAGE"}, selectTool: {"name":"simplepdf_embed_select_tool","description":"Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.","inputSchema":{"type":"object","properties":{"tool":{"anyOf":[{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"]},{"type":"null"}],"description":"Tool to activate, or null to deselect."}},"required":["tool"]},"annotations":{"destructiveHint":false},"wireType":"SELECT_TOOL"}, - setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Text values are typed out by default; pass animate: false to set them at once. Returns no data.","inputSchema":{"type":"object","properties":{"animate":{"description":"Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.","type":"boolean"},"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"SET_FIELD_VALUE"}, + setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.","inputSchema":{"type":"object","properties":{"animate":{"description":"Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.","type":"boolean"},"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"SET_FIELD_VALUE"}, submit: {"name":"simplepdf_embed_submit","description":"Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.","inputSchema":{"type":"object","properties":{"download_copy":{"description":"When true, the signer also receives a downloaded copy on submit.","type":"boolean"}},"required":["download_copy"]},"annotations":{"destructiveHint":true},"wireType":"SUBMIT"}, } as const satisfies Record diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index a6b10f24..50c97fef 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -166,7 +166,7 @@ describe('attachEmbed({ webMCP })', () => { // loadDocument is a host-page tool like it is in the editor's own registration. expect(modelContext.liveToolNames()).toContain('simplepdf_embed_load_document') const setFieldValue = findTool(modelContext, 'simplepdf_embed_set_field_value') - expect(setFieldValue.description).toMatch(/^Set the value of an existing field/) + expect(setFieldValue.description).toBe(WEBMCP_TOOLS.setFieldValue.description) expect(setFieldValue.inputSchema.type).toBe('object') expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual(['animate', 'field_id', 'value']) expect(setFieldValue.inputSchema.required).toEqual(['field_id', 'value']) From c3bf5cab9bd76eb5b2346f45e4970a75066a4882 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 13 Oct 2026 23:59:03 +0200 Subject: [PATCH 07/10] chore: ship source maps in every package, stop minifying the React build, web drops its browser field --- .../{smaller-packages.md => leaner-agent-requests.md} | 6 ++++-- embed/tsup.config.ts | 1 + package-lock.json | 11 +++++------ react/package.json | 1 - react/rollup.config.js | 8 ++------ react/tsconfig.json | 1 + web/package.json | 1 - web/rollup.config.js | 1 + 8 files changed, 14 insertions(+), 16 deletions(-) rename .changeset/{smaller-packages.md => leaner-agent-requests.md} (65%) diff --git a/.changeset/smaller-packages.md b/.changeset/leaner-agent-requests.md similarity index 65% rename from .changeset/smaller-packages.md rename to .changeset/leaner-agent-requests.md index 76d3bb82..898937d5 100644 --- a/.changeset/smaller-packages.md +++ b/.changeset/leaner-agent-requests.md @@ -1,11 +1,13 @@ --- "@simplepdf/embed": minor "@simplepdf/react-embed-pdf": patch +"@simplepdf/web-embed-pdf": patch --- -**Smaller installs and leaner agent requests.** +**Leaner agent requests and readable stack traces.** - The agentic tools no longer repeat each tool's description inside its input schema: every model request carrying all the AI SDK or TanStack AI tools sends about 4 KB less, and the agentic entry points are about 29% smaller. - Tool descriptions are about 17% shorter (697 fewer characters across the 17 tools): `setFieldValue` and `batchSetFieldValues` leave the value rules to their `value` parameter, permission notes are gone (a refused call answers with its error code), and `getAnnotatedPage` states that the WebMCP tool returns the render as an image block and the rest of the result as a text block. -- `@simplepdf/embed` and `@simplepdf/react-embed-pdf` no longer ship source maps: `@simplepdf/embed` unpacks to 263 KB instead of 645 KB, `@simplepdf/react-embed-pdf` to 41 KB instead of 113 KB. +- `@simplepdf/react-embed-pdf` is no longer minified, so an error inside it shows readable code in your devtools and error tracker; your bundler still minifies your app. +- `@simplepdf/web-embed-pdf` ships source maps for `dist/index.js` and `dist/index.min.js`, and drops its `browser` field, so bundlers resolve the readable `dist/index.js`; the script-tag build on unpkg stays minified. - The `/schemas` input schemas no longer carry the operation description at their root; read it from `SIMPLEPDF_TOOLS[name].description`, or from `OPERATIONS` in `@simplepdf/embed/protocol` for every operation including `loadDocument`. diff --git a/embed/tsup.config.ts b/embed/tsup.config.ts index d86ffe95..656dedd5 100644 --- a/embed/tsup.config.ts +++ b/embed/tsup.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ dts: true, treeshake: true, splitting: true, + sourcemap: true, clean: true, external: ['zod', '@tanstack/ai'], }) diff --git a/package-lock.json b/package-lock.json index 8a1c04a2..e989ba6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "embed": { "name": "@simplepdf/embed", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "devDependencies": { "@tanstack/ai": "^0.38.0", @@ -5783,13 +5783,12 @@ }, "react": { "name": "@simplepdf/react-embed-pdf", - "version": "1.13.0", + "version": "1.13.1", "license": "MIT", "dependencies": { - "@simplepdf/embed": "0.7.0" + "@simplepdf/embed": "0.8.0" }, "devDependencies": { - "@rollup/plugin-terser": "^0.4.4", "@tanstack/ai": "^0.38.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -6741,10 +6740,10 @@ }, "web": { "name": "@simplepdf/web-embed-pdf", - "version": "1.8.4", + "version": "1.9.1", "license": "MIT", "dependencies": { - "@simplepdf/embed": "0.7.0" + "@simplepdf/embed": "0.8.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.3", diff --git a/react/package.json b/react/package.json index c05513b9..19a66ee9 100644 --- a/react/package.json +++ b/react/package.json @@ -66,7 +66,6 @@ } }, "devDependencies": { - "@rollup/plugin-terser": "^0.4.4", "@tanstack/ai": "^0.38.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/react/rollup.config.js b/react/rollup.config.js index 1e6b2506..b626d792 100644 --- a/react/rollup.config.js +++ b/react/rollup.config.js @@ -2,7 +2,6 @@ import scss from 'rollup-plugin-scss'; import postcss from 'postcss'; import autoprefixer from 'autoprefixer'; import typescript from 'rollup-plugin-typescript2'; -import terser from '@rollup/plugin-terser'; // Dual CJS + ESM (so existing require() consumers keep working — this is a non-breaking minor). // react / react-dom / zod / the core are external (peer or dependency), resolved at the @@ -24,6 +23,7 @@ export default { dir: 'dist', format: 'cjs', exports: 'named', + sourcemap: true, entryFileNames: '[name].cjs', chunkFileNames: 'chunks/[name]-[hash].cjs', }, @@ -31,6 +31,7 @@ export default { dir: 'dist', format: 'es', exports: 'named', + sourcemap: true, entryFileNames: '[name].esm.js', chunkFileNames: 'chunks/[name]-[hash].esm.js', }, @@ -48,11 +49,6 @@ export default { exclude: ['node_modules', 'src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.test-d.ts', 'vitest.setup.ts'], }, }), - terser({ - format: { - comments: false, - }, - }), ], external: isExternal, }; diff --git a/react/tsconfig.json b/react/tsconfig.json index 1181cfa3..2ff760f6 100644 --- a/react/tsconfig.json +++ b/react/tsconfig.json @@ -4,6 +4,7 @@ "module": "esnext", "target": "es5", "lib": ["es6", "dom"], + "sourceMap": true, "allowJs": false, "jsx": "react", "declaration": true, diff --git a/web/package.json b/web/package.json index 9dbc7c6a..7787b356 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,6 @@ }, "type": "module", "main": "dist/index.js", - "browser": "dist/index.min.js", "unpkg": "dist/index.min.js", "types": "dist/index.d.ts", "author": "bendersej", diff --git a/web/rollup.config.js b/web/rollup.config.js index 2df3df01..4e59ba78 100644 --- a/web/rollup.config.js +++ b/web/rollup.config.js @@ -11,6 +11,7 @@ function createOutputConfig({ file, minify }) { inlineDynamicImports: true, name: 'simplePDF', strict: true, + sourcemap: true, plugins: minify ? [terser({ format: { comments: false } })] : [], }; } From e5604cd153d3ccffc2ff5028e2679e6725b29ead Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 14 Oct 2026 00:02:27 +0200 Subject: [PATCH 08/10] chore: no source maps in any package; the React build stays unminified --- .changeset/leaner-agent-requests.md | 6 +++--- embed/tsup.config.ts | 1 - react/rollup.config.js | 2 -- react/tsconfig.json | 1 - web/rollup.config.js | 1 - 5 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.changeset/leaner-agent-requests.md b/.changeset/leaner-agent-requests.md index 898937d5..c564de1b 100644 --- a/.changeset/leaner-agent-requests.md +++ b/.changeset/leaner-agent-requests.md @@ -4,10 +4,10 @@ "@simplepdf/web-embed-pdf": patch --- -**Leaner agent requests and readable stack traces.** +**Smaller installs, leaner agent requests, readable stack traces.** - The agentic tools no longer repeat each tool's description inside its input schema: every model request carrying all the AI SDK or TanStack AI tools sends about 4 KB less, and the agentic entry points are about 29% smaller. - Tool descriptions are about 17% shorter (697 fewer characters across the 17 tools): `setFieldValue` and `batchSetFieldValues` leave the value rules to their `value` parameter, permission notes are gone (a refused call answers with its error code), and `getAnnotatedPage` states that the WebMCP tool returns the render as an image block and the rest of the result as a text block. -- `@simplepdf/react-embed-pdf` is no longer minified, so an error inside it shows readable code in your devtools and error tracker; your bundler still minifies your app. -- `@simplepdf/web-embed-pdf` ships source maps for `dist/index.js` and `dist/index.min.js`, and drops its `browser` field, so bundlers resolve the readable `dist/index.js`; the script-tag build on unpkg stays minified. +- Smaller installs: no source maps, so `@simplepdf/embed` unpacks to 248 KB instead of 596 KB and `@simplepdf/react-embed-pdf` to 74 KB instead of 110 KB. `@simplepdf/react-embed-pdf` is no longer minified, so an error inside it shows readable code in your devtools and error tracker; your bundler still minifies your app. +- `@simplepdf/web-embed-pdf` drops its `browser` field, so bundlers resolve the readable `dist/index.js`; the script-tag build on unpkg stays minified. - The `/schemas` input schemas no longer carry the operation description at their root; read it from `SIMPLEPDF_TOOLS[name].description`, or from `OPERATIONS` in `@simplepdf/embed/protocol` for every operation including `loadDocument`. diff --git a/embed/tsup.config.ts b/embed/tsup.config.ts index 656dedd5..d86ffe95 100644 --- a/embed/tsup.config.ts +++ b/embed/tsup.config.ts @@ -19,7 +19,6 @@ export default defineConfig({ dts: true, treeshake: true, splitting: true, - sourcemap: true, clean: true, external: ['zod', '@tanstack/ai'], }) diff --git a/react/rollup.config.js b/react/rollup.config.js index b626d792..066c7647 100644 --- a/react/rollup.config.js +++ b/react/rollup.config.js @@ -23,7 +23,6 @@ export default { dir: 'dist', format: 'cjs', exports: 'named', - sourcemap: true, entryFileNames: '[name].cjs', chunkFileNames: 'chunks/[name]-[hash].cjs', }, @@ -31,7 +30,6 @@ export default { dir: 'dist', format: 'es', exports: 'named', - sourcemap: true, entryFileNames: '[name].esm.js', chunkFileNames: 'chunks/[name]-[hash].esm.js', }, diff --git a/react/tsconfig.json b/react/tsconfig.json index 2ff760f6..1181cfa3 100644 --- a/react/tsconfig.json +++ b/react/tsconfig.json @@ -4,7 +4,6 @@ "module": "esnext", "target": "es5", "lib": ["es6", "dom"], - "sourceMap": true, "allowJs": false, "jsx": "react", "declaration": true, diff --git a/web/rollup.config.js b/web/rollup.config.js index 4e59ba78..2df3df01 100644 --- a/web/rollup.config.js +++ b/web/rollup.config.js @@ -11,7 +11,6 @@ function createOutputConfig({ file, minify }) { inlineDynamicImports: true, name: 'simplePDF', strict: true, - sourcemap: true, plugins: minify ? [terser({ format: { comments: false } })] : [], }; } From e7dc0a0722847ffa7db9603166ec84e265da0620 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 14 Oct 2026 10:33:10 +0200 Subject: [PATCH 09/10] fix: tool descriptions name tools and keys in words, true on camelCase and snake_case surfaces; web ships the batch tool as a minor; release-note figures against 0.8.0 --- .changeset/batch-set-field-value.md | 4 +- .changeset/leaner-agent-requests.md | 5 +- embed/embed-api.json | 100 ++++++++++++++-------------- embed/etc/protocol.api.md | 28 ++++---- embed/etc/tools.api.md | 28 ++++---- embed/src/generated/contract.ts | 28 ++++---- embed/src/generated/schemas.ts | 8 +-- embed/src/generated/tools.ts | 28 ++++---- embed/src/generated/webmcp-tools.ts | 28 ++++---- web/tsconfig.json | 1 - 10 files changed, 128 insertions(+), 130 deletions(-) diff --git a/.changeset/batch-set-field-value.md b/.changeset/batch-set-field-value.md index 277cb39d..187d397c 100644 --- a/.changeset/batch-set-field-value.md +++ b/.changeset/batch-set-field-value.md @@ -1,11 +1,11 @@ --- "@simplepdf/embed": minor "@simplepdf/react-embed-pdf": minor +"@simplepdf/web-embed-pdf": minor --- **Fill several fields in one call: `batchSetFieldValues` sets every value at once, or none of them.** - - `batchSetFieldValues({ updates: [{ fieldId, value }] })` applies every update in one step, reverted by a single undo in the editor, and resolves with no data. - If any update is rejected (an unknown field, an invalid option, a read-only field, an unreadable signature URL, the same field twice), nothing changes: the result fails with `bad_request:batch_validation_failed` and `error.details.failures` lists every rejected update with its `fieldId`, `code` and `message` (typed as `BatchValidationFailedDetails`). - Values follow the `setFieldValue` rules and are never typed out. -- Available everywhere the editor operations are: `embed.actions.batchSetFieldValues`, `useEmbed().actions.batchSetFieldValues`, the `batchSetFieldValues` agentic tool on every tool subpath, and the `simplepdf_embed_batch_set_field_values` WebMCP tool. +- Available everywhere the editor operations are: `embed.actions.batchSetFieldValues`, `useEmbed().actions.batchSetFieldValues`, the `batchSetFieldValues` agentic tool on every tool subpath, and the `simplepdf_embed_batch_set_field_values` WebMCP tool, including on pages using `@simplepdf/web-embed-pdf`. diff --git a/.changeset/leaner-agent-requests.md b/.changeset/leaner-agent-requests.md index c564de1b..f2fa4b38 100644 --- a/.changeset/leaner-agent-requests.md +++ b/.changeset/leaner-agent-requests.md @@ -5,9 +5,8 @@ --- **Smaller installs, leaner agent requests, readable stack traces.** - -- The agentic tools no longer repeat each tool's description inside its input schema: every model request carrying all the AI SDK or TanStack AI tools sends about 4 KB less, and the agentic entry points are about 29% smaller. -- Tool descriptions are about 17% shorter (697 fewer characters across the 17 tools): `setFieldValue` and `batchSetFieldValues` leave the value rules to their `value` parameter, permission notes are gone (a refused call answers with its error code), and `getAnnotatedPage` states that the WebMCP tool returns the render as an image block and the rest of the result as a text block. +- The agentic tools no longer repeat each tool's description inside its input schema: with the shorter descriptions below, a model request carrying all the AI SDK or TanStack AI tools sends about 3 KB less (24%) even with the new `batchSetFieldValues` tool, and the agentic entry points are 28 to 29% smaller. +- Tool descriptions are about 24% shorter (900 fewer characters across the 16 tools of the previous release): they state behavior and leave the details to each parameter, permission notes are gone (a refused call answers with its error code), and they name no tool or key whose spelling differs between surfaces, so they read true whether your agent sees camelCase (AI SDK, TanStack AI) or snake_case (WebMCP). The signature `value` documents that plain text is drawn as a typed signature. `getAnnotatedPage` states that the WebMCP tool returns the render as an image block and the rest of the result as a text block. - Smaller installs: no source maps, so `@simplepdf/embed` unpacks to 248 KB instead of 596 KB and `@simplepdf/react-embed-pdf` to 74 KB instead of 110 KB. `@simplepdf/react-embed-pdf` is no longer minified, so an error inside it shows readable code in your devtools and error tracker; your bundler still minifies your app. - `@simplepdf/web-embed-pdf` drops its `browser` field, so bundlers resolve the readable `dist/index.js`; the script-tag build on unpkg stays minified. - The `/schemas` input schemas no longer carry the operation description at their root; read it from `SIMPLEPDF_TOOLS[name].description`, or from `OPERATIONS` in `@simplepdf/embed/protocol` for every operation including `loadDocument`. diff --git a/embed/embed-api.json b/embed/embed-api.json index 32b8aeb3..eb3a034e 100644 --- a/embed/embed-api.json +++ b/embed/embed-api.json @@ -78,7 +78,7 @@ "operations": [ { "request_type": "BATCH_SET_FIELD_VALUES", - "description": "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", + "description": "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -100,7 +100,7 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature." } }, "required": ["field_id", "value"] @@ -109,7 +109,7 @@ } }, "required": ["updates"], - "description": "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data." + "description": "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data." }, "output_schema": { "type": "null" @@ -121,7 +121,7 @@ ], "tool": { "name": "simplepdf_embed_batch_set_field_values", - "description": "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", + "description": "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -142,7 +142,7 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature." } }, "required": ["field_id", "value"] @@ -161,7 +161,7 @@ }, { "request_type": "CREATE_FIELD", - "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", + "description": "Create a new overlay field on a page. Returns the id of the created field.", "input_schema": { "type": "object", "properties": { @@ -191,12 +191,12 @@ "description": "1-based page to place the field on." }, "value": { - "description": "Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.", + "description": "Optional initial value. A string for text/checkbox fields; a data URL or http(s) URL (fetched by the editor) for signature/picture fields, or plain text for a signature, drawn as a typed signature.", "type": "string" } }, "required": ["type", "x", "y", "width", "height", "page"], - "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field." + "description": "Create a new overlay field on a page. Returns the id of the created field." }, "output_schema": { "type": "object", @@ -220,7 +220,7 @@ ], "tool": { "name": "simplepdf_embed_create_field", - "description": "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", + "description": "Create a new overlay field on a page. Returns the id of the created field.", "input_schema": { "type": "object", "properties": { @@ -250,7 +250,7 @@ "type": "integer" }, "value": { - "description": "Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.", + "description": "Optional initial value. A string for text/checkbox fields; a data URL or http(s) URL (fetched by the editor) for signature/picture fields, or plain text for a signature, drawn as a typed signature.", "type": "string" } }, @@ -264,7 +264,7 @@ }, { "request_type": "DELETE_FIELDS", - "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", + "description": "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive.", "input_schema": { "type": "object", "properties": { @@ -280,7 +280,7 @@ "type": "integer" } }, - "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive." + "description": "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive." }, "output_schema": { "type": "object", @@ -301,7 +301,7 @@ ], "tool": { "name": "simplepdf_embed_delete_fields", - "description": "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", + "description": "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive.", "input_schema": { "type": "object", "properties": { @@ -325,7 +325,7 @@ }, { "request_type": "DELETE_PAGES", - "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", + "description": "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -338,7 +338,7 @@ } }, "required": ["pages"], - "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive." + "description": "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive." }, "output_schema": { "type": "null" @@ -352,7 +352,7 @@ ], "tool": { "name": "simplepdf_embed_delete_pages", - "description": "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", + "description": "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -373,11 +373,11 @@ }, { "request_type": "DETECT_FIELDS", - "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", + "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected.", "input_schema": { "type": "object", "properties": {}, - "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }." + "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected." }, "output_schema": { "type": "object", @@ -394,7 +394,7 @@ ], "tool": { "name": "simplepdf_embed_detect_fields", - "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", + "description": "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected.", "input_schema": { "type": "object", "properties": {} @@ -434,7 +434,7 @@ }, { "request_type": "FOCUS_FIELD", - "description": "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", + "description": "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next.", "input_schema": { "type": "object", "properties": { @@ -444,7 +444,7 @@ } }, "required": ["field_id"], - "description": "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next." + "description": "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next." }, "output_schema": { "type": "object", @@ -472,7 +472,7 @@ ], "tool": { "name": "simplepdf_embed_focus_field", - "description": "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", + "description": "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next.", "input_schema": { "type": "object", "properties": { @@ -490,7 +490,7 @@ }, { "request_type": "GET_ANNOTATED_PAGE", - "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", + "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", "input_schema": { "type": "object", "properties": { @@ -500,7 +500,7 @@ } }, "required": ["page"], - "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block." + "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block." }, "output_schema": { "type": "object", @@ -544,7 +544,7 @@ ], "tool": { "name": "simplepdf_embed_get_annotated_page", - "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", + "description": "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", "input_schema": { "type": "object", "properties": { @@ -563,7 +563,7 @@ }, { "request_type": "GET_DOCUMENT_CONTENT", - "description": "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", + "description": "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content.", "input_schema": { "type": "object", "properties": { @@ -573,7 +573,7 @@ "enum": ["auto", "ocr"] } }, - "description": "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }." + "description": "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content." }, "output_schema": { "type": "object", @@ -605,7 +605,7 @@ ], "tool": { "name": "simplepdf_embed_get_document_content", - "description": "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", + "description": "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content.", "input_schema": { "type": "object", "properties": { @@ -624,11 +624,11 @@ }, { "request_type": "GET_FIELDS", - "description": "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", + "description": "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list.", "input_schema": { "type": "object", "properties": {}, - "description": "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }." + "description": "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list." }, "output_schema": { "type": "object", @@ -709,7 +709,7 @@ "error_codes": ["bad_request:no_document_loaded"], "tool": { "name": "simplepdf_embed_get_fields", - "description": "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", + "description": "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list.", "input_schema": { "type": "object", "properties": {} @@ -722,7 +722,7 @@ }, { "request_type": "GO_TO", - "description": "Scroll the editor to a specific 1-based page. Returns no data.", + "description": "Scroll the editor to a page. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -732,7 +732,7 @@ } }, "required": ["page"], - "description": "Scroll the editor to a specific 1-based page. Returns no data." + "description": "Scroll the editor to a page. Returns no data." }, "output_schema": { "type": "null" @@ -744,7 +744,7 @@ ], "tool": { "name": "simplepdf_embed_go_to", - "description": "Scroll the editor to a specific 1-based page. Returns no data.", + "description": "Scroll the editor to a page. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -819,7 +819,7 @@ }, { "request_type": "MOVE_PAGE", - "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", + "description": "Move a page to another position, reordering the document. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -833,7 +833,7 @@ } }, "required": ["from_page", "to_page"], - "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive." + "description": "Move a page to another position, reordering the document. Returns no data. Destructive." }, "output_schema": { "type": "null" @@ -847,7 +847,7 @@ ], "tool": { "name": "simplepdf_embed_move_page", - "description": "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", + "description": "Move a page to another position, reordering the document. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { @@ -869,17 +869,17 @@ }, { "request_type": "ROTATE_PAGE", - "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", + "description": "Rotate a page 90 degrees clockwise. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { "page": { "type": "integer", - "description": "1-based page to rotate 90 degrees clockwise." + "description": "1-based page to rotate." } }, "required": ["page"], - "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive." + "description": "Rotate a page 90 degrees clockwise. Returns no data. Destructive." }, "output_schema": { "type": "null" @@ -893,12 +893,12 @@ ], "tool": { "name": "simplepdf_embed_rotate_page", - "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", + "description": "Rotate a page 90 degrees clockwise. Returns no data. Destructive.", "input_schema": { "type": "object", "properties": { "page": { - "description": "1-based page to rotate 90 degrees clockwise.", + "description": "1-based page to rotate.", "type": "integer" } }, @@ -978,7 +978,7 @@ }, { "request_type": "SET_FIELD_VALUE", - "description": "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", + "description": "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -999,11 +999,11 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature." } }, "required": ["field_id", "value"], - "description": "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data." + "description": "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data." }, "output_schema": { "type": "null" @@ -1017,7 +1017,7 @@ ], "tool": { "name": "simplepdf_embed_set_field_value", - "description": "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", + "description": "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -1038,7 +1038,7 @@ "type": "null" } ], - "description": "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature." } }, "required": ["field_id", "value"] @@ -1051,7 +1051,7 @@ }, { "request_type": "SUBMIT", - "description": "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", + "description": "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data.", "input_schema": { "type": "object", "properties": { @@ -1061,7 +1061,7 @@ } }, "required": ["download_copy"], - "description": "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data." + "description": "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data." }, "output_schema": { "type": "null" @@ -1073,7 +1073,7 @@ ], "tool": { "name": "simplepdf_embed_submit", - "description": "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", + "description": "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data.", "input_schema": { "type": "object", "properties": { diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index 7cf19d5f..46a405dd 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -30,7 +30,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "BATCH_SET_FIELD_VALUES"; readonly wire_type: "BATCH_SET_FIELD_VALUES"; readonly method: "batchSetFieldValues"; - readonly description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data."; + readonly description: "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data."; readonly error_codes: readonly ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -38,7 +38,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "CREATE_FIELD"; readonly wire_type: "CREATE_FIELD"; readonly method: "createField"; - readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field."; + readonly description: "Create a new overlay field on a page. Returns the id of the created field."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -46,7 +46,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "DELETE_FIELDS"; readonly wire_type: "DELETE_FIELDS"; readonly method: "deleteFields"; - readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive."; + readonly description: "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -54,7 +54,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "DELETE_PAGES"; readonly wire_type: "DELETE_PAGES"; readonly method: "deletePages"; - readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive."; + readonly description: "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -62,7 +62,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "DETECT_FIELDS"; readonly wire_type: "DETECT_FIELDS"; readonly method: "detectFields"; - readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }."; + readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -78,7 +78,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "FOCUS_FIELD"; readonly wire_type: "FOCUS_FIELD"; readonly method: "focusField"; - readonly description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next."; + readonly description: "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next."; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -86,7 +86,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_ANNOTATED_PAGE"; readonly wire_type: "GET_ANNOTATED_PAGE"; readonly method: "getAnnotatedPage"; - readonly description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block."; + readonly description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block."; readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -94,7 +94,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_DOCUMENT_CONTENT"; readonly wire_type: "GET_DOCUMENT_CONTENT"; readonly method: "getDocumentContent"; - readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly description: "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content."; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -102,7 +102,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_FIELDS"; readonly wire_type: "GET_FIELDS"; readonly method: "getFields"; - readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }."; + readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list."; readonly error_codes: readonly ["bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -110,7 +110,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GO_TO"; readonly wire_type: "GO_TO"; readonly method: "goTo"; - readonly description: "Scroll the editor to a specific 1-based page. Returns no data."; + readonly description: "Scroll the editor to a page. Returns no data."; readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -126,7 +126,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "MOVE_PAGE"; readonly wire_type: "MOVE_PAGE"; readonly method: "movePage"; - readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive."; + readonly description: "Move a page to another position, reordering the document. Returns no data. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -134,7 +134,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "ROTATE_PAGE"; readonly wire_type: "ROTATE_PAGE"; readonly method: "rotatePage"; - readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive."; + readonly description: "Rotate a page 90 degrees clockwise. Returns no data. Destructive."; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -150,7 +150,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "SET_FIELD_VALUE"; readonly wire_type: "SET_FIELD_VALUE"; readonly method: "setFieldValue"; - readonly description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data."; + readonly description: "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data."; readonly error_codes: readonly ["bad_request:field_not_found", "bad_request:invalid_signature_url", "bad_request:invalid_value", "bad_request:read_only", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -158,7 +158,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "SUBMIT"; readonly wire_type: "SUBMIT"; readonly method: "submit"; - readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data."; + readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data."; readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:invalid_value", "bad_request:missing_required_fields"]; readonly is_agentic_tool: true; readonly has_output: false; diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md index 5f06cc94..e52b690c 100644 --- a/embed/etc/tools.api.md +++ b/embed/etc/tools.api.md @@ -19,7 +19,7 @@ export const routeToolCall: (actions: IframeActions, toolName: SimplePDFToolName // @public (undocumented) export const SIMPLEPDF_TOOLS: { readonly batchSetFieldValues: { - readonly description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data."; + readonly description: "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data."; readonly inputSchema: zod.ZodObject<{ updates: zod.ZodArray; }; readonly createField: { - readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field."; + readonly description: "Create a new overlay field on a page. Returns the id of the created field."; readonly inputSchema: zod.ZodObject<{ type: zod.ZodEnum<{ TEXT: "TEXT"; @@ -46,20 +46,20 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly deleteFields: { - readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive."; + readonly description: "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive."; readonly inputSchema: zod.ZodObject<{ fieldIds: zod.ZodOptional>; page: zod.ZodOptional; }, zod_v4_core.$strip>; }; readonly deletePages: { - readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive."; + readonly description: "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive."; readonly inputSchema: zod.ZodObject<{ pages: zod.ZodArray; }, zod_v4_core.$strip>; }; readonly detectFields: { - readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }."; + readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected."; readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; }; readonly download: { @@ -67,19 +67,19 @@ export const SIMPLEPDF_TOOLS: { readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; }; readonly focusField: { - readonly description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next."; + readonly description: "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next."; readonly inputSchema: zod.ZodObject<{ fieldId: zod.ZodString; }, zod_v4_core.$strip>; }; readonly getAnnotatedPage: { - readonly description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block."; + readonly description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block."; readonly inputSchema: zod.ZodObject<{ page: zod.ZodNumber; }, zod_v4_core.$strip>; }; readonly getDocumentContent: { - readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly description: "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content."; readonly inputSchema: zod.ZodObject<{ extractionMode: zod.ZodOptional; }; readonly getFields: { - readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }."; + readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list."; readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; }; readonly goTo: { - readonly description: "Scroll the editor to a specific 1-based page. Returns no data."; + readonly description: "Scroll the editor to a page. Returns no data."; readonly inputSchema: zod.ZodObject<{ page: zod.ZodNumber; }, zod_v4_core.$strip>; }; readonly movePage: { - readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive."; + readonly description: "Move a page to another position, reordering the document. Returns no data. Destructive."; readonly inputSchema: zod.ZodObject<{ fromPage: zod.ZodNumber; toPage: zod.ZodNumber; }, zod_v4_core.$strip>; }; readonly rotatePage: { - readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive."; + readonly description: "Rotate a page 90 degrees clockwise. Returns no data. Destructive."; readonly inputSchema: zod.ZodObject<{ page: zod.ZodNumber; }, zod_v4_core.$strip>; @@ -123,7 +123,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly setFieldValue: { - readonly description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data."; + readonly description: "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data."; readonly inputSchema: zod.ZodObject<{ animate: zod.ZodOptional; fieldId: zod.ZodString; @@ -131,7 +131,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly submit: { - readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data."; + readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data."; readonly inputSchema: zod.ZodObject<{ downloadCopy: zod.ZodBoolean; }, zod_v4_core.$strip>; diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index 12011eed..6addc71b 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -72,7 +72,7 @@ export const OPERATIONS = [ request_type: "BATCH_SET_FIELD_VALUES", wire_type: "BATCH_SET_FIELD_VALUES", method: "batchSetFieldValues", - description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", + description: "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data.", error_codes: ["bad_request:batch_validation_failed", "bad_request:invalid_value", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: false, @@ -81,7 +81,7 @@ export const OPERATIONS = [ request_type: "CREATE_FIELD", wire_type: "CREATE_FIELD", method: "createField", - description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", + description: "Create a new overlay field on a page. Returns the id of the created field.", error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"] as const, is_agentic_tool: true, has_output: true, @@ -90,7 +90,7 @@ export const OPERATIONS = [ request_type: "DELETE_FIELDS", wire_type: "DELETE_FIELDS", method: "deleteFields", - description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", + description: "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -99,7 +99,7 @@ export const OPERATIONS = [ request_type: "DELETE_PAGES", wire_type: "DELETE_PAGES", method: "deletePages", - description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", + description: "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -108,7 +108,7 @@ export const OPERATIONS = [ request_type: "DETECT_FIELDS", wire_type: "DETECT_FIELDS", method: "detectFields", - description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", + description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected.", error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -126,7 +126,7 @@ export const OPERATIONS = [ request_type: "FOCUS_FIELD", wire_type: "FOCUS_FIELD", method: "focusField", - description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", + description: "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next.", error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -135,7 +135,7 @@ export const OPERATIONS = [ request_type: "GET_ANNOTATED_PAGE", wire_type: "GET_ANNOTATED_PAGE", method: "getAnnotatedPage", - description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", + description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", error_codes: ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"] as const, is_agentic_tool: true, has_output: true, @@ -144,7 +144,7 @@ export const OPERATIONS = [ request_type: "GET_DOCUMENT_CONTENT", wire_type: "GET_DOCUMENT_CONTENT", method: "getDocumentContent", - description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", + description: "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content.", error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -153,7 +153,7 @@ export const OPERATIONS = [ request_type: "GET_FIELDS", wire_type: "GET_FIELDS", method: "getFields", - description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", + description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list.", error_codes: ["bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -162,7 +162,7 @@ export const OPERATIONS = [ request_type: "GO_TO", wire_type: "GO_TO", method: "goTo", - description: "Scroll the editor to a specific 1-based page. Returns no data.", + description: "Scroll the editor to a page. Returns no data.", error_codes: ["bad_request:no_document_loaded", "bad_request:invalid_page", "bad_request:page_out_of_range"] as const, is_agentic_tool: true, has_output: false, @@ -180,7 +180,7 @@ export const OPERATIONS = [ request_type: "MOVE_PAGE", wire_type: "MOVE_PAGE", method: "movePage", - description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", + description: "Move a page to another position, reordering the document. Returns no data. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -189,7 +189,7 @@ export const OPERATIONS = [ request_type: "ROTATE_PAGE", wire_type: "ROTATE_PAGE", method: "rotatePage", - description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", + description: "Rotate a page 90 degrees clockwise. Returns no data. Destructive.", error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -207,7 +207,7 @@ export const OPERATIONS = [ request_type: "SET_FIELD_VALUE", wire_type: "SET_FIELD_VALUE", method: "setFieldValue", - description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", + description: "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data.", error_codes: ["bad_request:field_not_found", "bad_request:invalid_signature_url", "bad_request:invalid_value", "bad_request:read_only", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: false, @@ -216,7 +216,7 @@ export const OPERATIONS = [ request_type: "SUBMIT", wire_type: "SUBMIT", method: "submit", - description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", + description: "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data.", error_codes: ["bad_request:no_document_loaded", "bad_request:invalid_value", "bad_request:missing_required_fields"] as const, is_agentic_tool: true, has_output: false, diff --git a/embed/src/generated/schemas.ts b/embed/src/generated/schemas.ts index 5078c758..eaa4055d 100644 --- a/embed/src/generated/schemas.ts +++ b/embed/src/generated/schemas.ts @@ -4,7 +4,7 @@ import { z } from 'zod' export const BatchSetFieldValuesInput = z.object({ updates: z.array(z.object({ fieldId: z.string().describe("ID of the field to update."), - value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), + value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature."), })).describe("The fields to set, at least one, each field at most once. Fields not listed keep their value."), }) export type BatchSetFieldValuesInput = z.infer @@ -15,7 +15,7 @@ export const CreateFieldInput = z.object({ width: z.number().describe("Field width, in PDF points."), height: z.number().describe("Field height, in PDF points."), page: z.number().int().describe("1-based page to place the field on."), - value: z.string().describe("Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.").optional(), + value: z.string().describe("Optional initial value. A string for text/checkbox fields; a data URL or http(s) URL (fetched by the editor) for signature/picture fields, or plain text for a signature, drawn as a typed signature.").optional(), }) export type CreateFieldInput = z.infer export const DeleteFieldsInput = z.object({ @@ -61,7 +61,7 @@ export const MovePageInput = z.object({ }) export type MovePageInput = z.infer export const RotatePageInput = z.object({ - page: z.number().int().describe("1-based page to rotate 90 degrees clockwise."), + page: z.number().int().describe("1-based page to rotate."), }) export type RotatePageInput = z.infer export const SelectToolInput = z.object({ @@ -71,7 +71,7 @@ export type SelectToolInput = z.infer export const SetFieldValueInput = z.object({ animate: z.boolean().describe("Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.").optional(), fieldId: z.string().describe("ID of the field to update."), - value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), + value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature."), }) export type SetFieldValueInput = z.infer export const SubmitInput = z.object({ diff --git a/embed/src/generated/tools.ts b/embed/src/generated/tools.ts index 5b6bb3a8..d18bcc6a 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -4,22 +4,22 @@ import * as Schemas from './schemas' // The agentic tool registry. Each tool name is the camelCase operation name; // load_document is excluded here (a host/setup action; the WebMCP surface registers it). export const TOOL_DEFINITIONS = { - batchSetFieldValues: { description: "Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.", inputSchema: Schemas.BatchSetFieldValuesInput }, - createField: { description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.", inputSchema: Schemas.CreateFieldInput }, - deleteFields: { description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.", inputSchema: Schemas.DeleteFieldsInput }, - deletePages: { description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.", inputSchema: Schemas.DeletePagesInput }, - detectFields: { description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.", inputSchema: Schemas.DetectFieldsInput }, + batchSetFieldValues: { description: "Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data.", inputSchema: Schemas.BatchSetFieldValuesInput }, + createField: { description: "Create a new overlay field on a page. Returns the id of the created field.", inputSchema: Schemas.CreateFieldInput }, + deleteFields: { description: "Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive.", inputSchema: Schemas.DeleteFieldsInput }, + deletePages: { description: "Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive.", inputSchema: Schemas.DeletePagesInput }, + detectFields: { description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected.", inputSchema: Schemas.DetectFieldsInput }, download: { description: "Generate and download the current document as a PDF. Returns no data.", inputSchema: Schemas.DownloadInput }, - focusField: { description: "Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.", inputSchema: Schemas.FocusFieldInput }, - getAnnotatedPage: { description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", inputSchema: Schemas.GetAnnotatedPageInput }, - getDocumentContent: { description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", inputSchema: Schemas.GetDocumentContentInput }, - getFields: { description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", inputSchema: Schemas.GetFieldsInput }, - goTo: { description: "Scroll the editor to a specific 1-based page. Returns no data.", inputSchema: Schemas.GoToInput }, - movePage: { description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.", inputSchema: Schemas.MovePageInput }, - rotatePage: { description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.", inputSchema: Schemas.RotatePageInput }, + focusField: { description: "Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next.", inputSchema: Schemas.FocusFieldInput }, + getAnnotatedPage: { description: "Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.", inputSchema: Schemas.GetAnnotatedPageInput }, + getDocumentContent: { description: "Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content.", inputSchema: Schemas.GetDocumentContentInput }, + getFields: { description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list.", inputSchema: Schemas.GetFieldsInput }, + goTo: { description: "Scroll the editor to a page. Returns no data.", inputSchema: Schemas.GoToInput }, + movePage: { description: "Move a page to another position, reordering the document. Returns no data. Destructive.", inputSchema: Schemas.MovePageInput }, + rotatePage: { description: "Rotate a page 90 degrees clockwise. Returns no data. Destructive.", inputSchema: Schemas.RotatePageInput }, selectTool: { description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.", inputSchema: Schemas.SelectToolInput }, - setFieldValue: { description: "Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.", inputSchema: Schemas.SetFieldValueInput }, - submit: { description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", inputSchema: Schemas.SubmitInput }, + setFieldValue: { description: "Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data.", inputSchema: Schemas.SetFieldValueInput }, + submit: { description: "Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data.", inputSchema: Schemas.SubmitInput }, } as const export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS diff --git a/embed/src/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts index c62271c8..36854d9c 100644 --- a/embed/src/generated/webmcp-tools.ts +++ b/embed/src/generated/webmcp-tools.ts @@ -25,21 +25,21 @@ export type WebMCPToolRecord = { } export const WEBMCP_TOOLS = { - batchSetFieldValues: {"name":"simplepdf_embed_batch_set_field_values","description":"Set existing fields, addressed by ids from get_fields, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details.failures lists each rejection (field_id, code, message). Text is set at once, never typed. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUES"}, - createField: {"name":"simplepdf_embed_create_field","description":"Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, - deleteFields: {"name":"simplepdf_embed_delete_fields","description":"Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, - deletePages: {"name":"simplepdf_embed_delete_pages","description":"Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, - detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DETECT_FIELDS"}, + batchSetFieldValues: {"name":"simplepdf_embed_batch_set_field_values","description":"Set existing fields, addressed by ids from the field list, in one atomic undo step. If any update is rejected, nothing changes and the call fails with bad_request:batch_validation_failed, whose details list each rejection with its field id, code and message. Text is set at once, never typed. Returns no data.","inputSchema":{"type":"object","properties":{"updates":{"items":{"type":"object","properties":{"field_id":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature."}},"required":["field_id","value"]},"description":"The fields to set, at least one, each field at most once. Fields not listed keep their value.","type":"array"}},"required":["updates"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"BATCH_SET_FIELD_VALUES"}, + createField: {"name":"simplepdf_embed_create_field","description":"Create a new overlay field on a page. Returns the id of the created field.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields; a data URL or http(s) URL (fetched by the editor) for signature/picture fields, or plain text for a signature, drawn as a typed signature.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, + deleteFields: {"name":"simplepdf_embed_delete_fields","description":"Delete overlay fields: the ones listed, every one on a page, or every one in the document. Returns the number of fields deleted. Destructive.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, + deletePages: {"name":"simplepdf_embed_delete_pages","description":"Delete pages from the document (it cannot delete every visible page). Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, + detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns the number of fields detected.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DETECT_FIELDS"}, download: {"name":"simplepdf_embed_download","description":"Generate and download the current document as a PDF. Returns no data.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DOWNLOAD"}, - focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id from get_fields. Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false},"wireType":"FOCUS_FIELD"}, - getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values with get_fields). Returns { page, image_data_url, image_width, image_height, badges }, where badges maps each number drawn on the image to its field_id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_ANNOTATED_PAGE"}, - getDocumentContent: {"name":"simplepdf_embed_get_document_content","description":"Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.","inputSchema":{"type":"object","properties":{"extraction_mode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","enum":["auto","ocr"],"type":"string"}}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_DOCUMENT_CONTENT"}, - getFields: {"name":"simplepdf_embed_get_fields","description":"List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_FIELDS"}, - goTo: {"name":"simplepdf_embed_go_to","description":"Scroll the editor to a specific 1-based page. Returns no data.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to navigate to.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":false},"wireType":"GO_TO"}, + focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id from the field list. Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false},"wireType":"FOCUS_FIELD"}, + getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field outlined and numbered, so a vision model can see which field sits where on the printed form (placement only: read values from the field list). Returns the page, the PNG as a data URL, its width and height in pixels, and badges mapping each number drawn on the image to a field id; as a WebMCP tool the PNG arrives as an image block and the rest as a text block.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_ANNOTATED_PAGE"}, + getDocumentContent: {"name":"simplepdf_embed_get_document_content","description":"Extract the document's content page by page as Markdown, or as plain text when optical recognition is forced. Use it to read what the document says. Returns the document name and each page's content.","inputSchema":{"type":"object","properties":{"extraction_mode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","enum":["auto","ocr"],"type":"string"}}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_DOCUMENT_CONTENT"}, + getFields: {"name":"simplepdf_embed_get_fields","description":"List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, current value, and the options its value must be one of, if any. Call this first to discover field ids before reading or setting values. To SEE where each field sits on the printed page, render the annotated page. Returns the field list.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_FIELDS"}, + goTo: {"name":"simplepdf_embed_go_to","description":"Scroll the editor to a page. Returns no data.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to navigate to.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":false},"wireType":"GO_TO"}, loadDocument: {"name":"simplepdf_embed_load_document","description":"Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Replies once the document and its fields are ready, so the next operation can act on it, and DOCUMENT_LOADED follows the reply; the URL already open is left as is and answers once it settles; a document that cannot be loaded or prepared answers bad_request:failed_to_load_document. Returns no data.","inputSchema":{"type":"object","properties":{"data_url":{"description":"The document to load: a data URL, or an http(s) URL the editor fetches.","type":"string"},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["data_url"]},"annotations":{"destructiveHint":true,"openWorldHint":true},"wireType":"LOAD_DOCUMENT"}, - movePage: {"name":"simplepdf_embed_move_page","description":"Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true},"wireType":"MOVE_PAGE"}, - rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate 90 degrees clockwise.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true},"wireType":"ROTATE_PAGE"}, + movePage: {"name":"simplepdf_embed_move_page","description":"Move a page to another position, reordering the document. Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true},"wireType":"MOVE_PAGE"}, + rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a page 90 degrees clockwise. Returns no data. Destructive.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true},"wireType":"ROTATE_PAGE"}, selectTool: {"name":"simplepdf_embed_select_tool","description":"Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.","inputSchema":{"type":"object","properties":{"tool":{"anyOf":[{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"]},{"type":"null"}],"description":"Tool to activate, or null to deselect."}},"required":["tool"]},"annotations":{"destructiveHint":false},"wireType":"SELECT_TOOL"}, - setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set or clear (null) one existing field, addressed by its id from get_fields. Text is typed out so a person watching can follow along, unless animate is false. Returns no data.","inputSchema":{"type":"object","properties":{"animate":{"description":"Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.","type":"boolean"},"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"SET_FIELD_VALUE"}, - submit: {"name":"simplepdf_embed_submit","description":"Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.","inputSchema":{"type":"object","properties":{"download_copy":{"description":"When true, the signer also receives a downloaded copy on submit.","type":"boolean"}},"required":["download_copy"]},"annotations":{"destructiveHint":true},"wireType":"SUBMIT"}, + setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set or clear (null) one existing field, addressed by its id from the field list. Text is typed out unless animate is false. Returns no data.","inputSchema":{"type":"object","properties":{"animate":{"description":"Type a text value out character by character (true, the default) so a person watching the editor can follow the field being filled, or set it at once (false) for headless or batch filling where speed matters. Only text values are typed: checkbox, option, signature, picture and null values are always set at once.","type":"boolean"},"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox), or a data URL or http(s) URL, fetched by the editor (signature/picture), or plain text for a signature, drawn as a typed signature."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"SET_FIELD_VALUE"}, + submit: {"name":"simplepdf_embed_submit","description":"Submit the completed document through the editor's finalization flow. This is irreversible. Fails with bad_request:missing_required_fields when required fields are unfilled. Returns no data.","inputSchema":{"type":"object","properties":{"download_copy":{"description":"When true, the signer also receives a downloaded copy on submit.","type":"boolean"}},"required":["download_copy"]},"annotations":{"destructiveHint":true},"wireType":"SUBMIT"}, } as const satisfies Record diff --git a/web/tsconfig.json b/web/tsconfig.json index 3a8e67f6..098d6f4c 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -4,7 +4,6 @@ "module": "esnext", "target": "es5", "lib": ["es2020", "dom"], - "sourceMap": true, "allowJs": false, "jsx": "react", "declaration": true, From d952e2f71c51df778768f328eab7079d563d2d41 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 14 Oct 2026 12:15:18 +0200 Subject: [PATCH 10/10] chore: pin the contract to the live editor (7845f7c3) --- embed/embed-api.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embed/embed-api.json b/embed/embed-api.json index eb3a034e..41bc6286 100644 --- a/embed/embed-api.json +++ b/embed/embed-api.json @@ -1,5 +1,5 @@ { - "editor_version": null, + "editor_version": "7845f7c3-20261014T100758Z", "description": "This is the SimplePDF editor interface contract. Drive the editor programmatically over window.postMessage with the editor iframe: post a JSON string { \"type\": {{operation request_type}}, \"request_id\": {{a request correlation id you generate}}, \"data\": {{input matching input_schema}} } to the iframe; the editor replies with { \"type\": \"REQUEST_RESULT\", \"data\": { \"request_id\": {{the same correlation id}}, \"result\": {{result}} } } (match each reply to its request by this id), where result is { \"success\": true, \"data\": {{a value matching the operation's output_schema; null for ops that return nothing}} } or { \"success\": false, \"error\": { code, message } }. Each operation lists the op-specific `error_codes` it can return; on top of those, any op may also fail with a gateway/permission code (origin/plan/signup gating, editor-not-ready, or an internal error). Every code is within `editor_error_schema` (the complete closed union to narrow against), where each code const carries a `description` of its meaning. Outbound events (see `events`) are pushed the same way. `operations` lists every operation the editor supports. Most require the embedding origin to be allowlisted (\"whitelisted\") for the tenant in the SimplePDF admin dashboard, and the tenant plan to permit them (LOAD_DOCUMENT is always available). A call the current setup does not permit returns the matching gateway code: forbidden:origin_not_whitelisted when the origin is not allowlisted, or bad_request:plan_upgrade_required when the plan excludes it. All JSON schemas use the json_schema_dialect declared at the root. We recommend the @simplepdf/embed package for a typed, ergonomic wrapper over this contract (https://github.com/SimplePDF/simplepdf-embed).", "json_schema_dialect": "https://json-schema.org/draft/2020-12/schema", "protocol": {