From 0f1d9b697ba7f3f2ce5c3a17927095d65629003d Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 1 Oct 2026 12:12:55 +0100 Subject: [PATCH 1/3] Reduce web JavaScript bundle size Compact generated protocol metadata and enable full Oxc library minification while preserving protocol and telemetry behavior. Cache model lookups and support schema growth in the compact encoding. Orchestrated-by: ae --- autoresearch.config.json | 1 + autoresearch.jsonl | 25 ++++ autoresearch.md | 56 +++++++ autoresearch.sh | 28 ++++ platforms/web/src/checkout.ts | 12 +- .../src/generated/ProtocolCodecs.ts | 23 +-- .../src/generated/ProtocolNotifications.ts | 17 +-- .../src/generated/ProtocolRenameMap.ts | 96 +++++++++++- .../src/protocol_codec_runtime.d.ts | 4 +- .../typescript/src/protocol_codec_runtime.ts | 115 +++++++------- .../typescript/src/protocol_rename_map.d.ts | 3 + .../typescript/src/protocol_rename_map.ts | 33 ++++ .../typescript/test/codec-runtime.test.ts | 92 ++++++++---- .../typescript/test/payloads.test.ts | 3 +- .../test/rename-map-encoding.test.ts | 141 ++++++++++++++++++ .../typescript/test/spec-metadata.test.ts | 13 +- .../scripts/generate_typescript_codecs.mjs | 5 +- .../generate_typescript_notifications.mjs | 4 +- .../generate_typescript_rename_map.mjs | 100 +++++++++++-- telemetry/languages/typescript/src/client.ts | 44 +++--- .../languages/typescript/test/client.test.ts | 3 + 21 files changed, 647 insertions(+), 171 deletions(-) create mode 100644 autoresearch.config.json create mode 100644 autoresearch.jsonl create mode 100644 autoresearch.md create mode 100755 autoresearch.sh create mode 100644 protocol/languages/typescript/src/protocol_rename_map.d.ts create mode 100644 protocol/languages/typescript/src/protocol_rename_map.ts create mode 100644 protocol/languages/typescript/test/rename-map-encoding.test.ts diff --git a/autoresearch.config.json b/autoresearch.config.json new file mode 100644 index 000000000..eb4f1e608 --- /dev/null +++ b/autoresearch.config.json @@ -0,0 +1 @@ +{"maxIterations":50,"maxCostUsd":50} \ No newline at end of file diff --git a/autoresearch.jsonl b/autoresearch.jsonl new file mode 100644 index 000000000..7fc277a29 --- /dev/null +++ b/autoresearch.jsonl @@ -0,0 +1,25 @@ +{"type":"config","name":"Reduce Checkout Kit web production JavaScript","metricName":"js_bytes","metricUnit":"","bestDirection":"lower"} +{"run":1,"commit":"e82fffc","metric":0,"metrics":{"gzip_bytes":0},"status":"crash","description":"Baseline build could not start because platforms/web dependencies are not provisioned (vite not found).","timestamp":1790851500524,"segment":0} +{"run":2,"commit":"a821210","metric":42204,"metrics":{"gzip_bytes":11490},"status":"keep","description":"Fresh origin/main baseline after provisioning dependencies; standard web production build emitted one runtime file and passed declaration/manifest generation.","timestamp":1790851583601,"segment":0} +{"run":3,"commit":"9dd9782","metric":41895,"metrics":{"gzip_bytes":11458},"status":"keep","description":"Derive telemetry's protocol-method Set from the existing generated notification/request catalogs instead of emitting 15 duplicate wire-method strings. 42,204 -> 41,895 bytes; standard build and 75 TypeScript protocol tests passed.","timestamp":1790851672068,"segment":0} +{"run":4,"commit":"9dd9782","metric":0,"metrics":{"gzip_bytes":0},"status":"crash","description":"Expose individually tree-shakeable protocol descriptors and consume them directly in web. Build emitted 41,674 bytes but declaration generation reported stale protocol .d.ts exports, so the experiment is invalid until declarations are regenerated.","timestamp":1790851762175,"segment":0} +{"run":5,"commit":"9dd9782","metric":41914,"metrics":{"gzip_bytes":11411},"status":"discard","description":"Mark individually exported generated descriptors pure so Rollup can prune unused protocol request codecs. The result regressed the retained 41,895-byte bundle to 41,914 bytes, so discard the descriptor-export approach.","timestamp":1790851814046,"segment":0} +{"run":6,"commit":"bb01a98","metric":39058,"metrics":{"gzip_bytes":11139},"status":"keep","description":"Store only protocol wire names in the generated rename table and derive their camelCase TypeScript names during codec traversal, with codegen validation that every generated name is derivable. 41,895 -> 39,058 bytes; standard build and all 75 TypeScript protocol tests passed.","timestamp":1790851894845,"segment":0} +{"run":7,"commit":"bb01a98","metric":0,"metrics":{"gzip_bytes":0},"status":"crash","description":"Index generated protocol model references numerically, reducing the build to 37,029 bytes, but six internal codec tests still called the changed private runtime helper with model-name strings. Update those tests to use generated model IDs before evaluating.","timestamp":1790852006145,"segment":0} +{"run":8,"commit":"f79dfec0","metric":37029,"metrics":{"gzip_bytes":10900},"status":"keep","description":"Replace generated protocol model-name references and object keys with deterministic numeric model IDs while preserving labels for validation errors. 39,058 -> 37,029 bytes; standard build and all 75 TypeScript protocol tests passed.","timestamp":1790852061308,"segment":0} +{"run":9,"commit":"f79dfec","metric":71154,"metrics":{"gzip_bytes":13972},"status":"discard","description":"Explicitly use Vite's esbuild minifier to test whether it removes Rolldown region formatting. It produced 71,154 bytes versus 37,029, so keep the existing Oxc/default minifier.","timestamp":1790852129141,"segment":0} +{"run":10,"commit":"8a08156","metric":29397,"metrics":{"gzip_bytes":9750},"status":"keep","description":"Enable Oxc code generation in Rolldown's library output minifier. Vite 8 intentionally defaulted ES-library minification to compress/mangle without codegen, leaving formatting and region comments. 37,029 -> 29,397 bytes; standard build and all 302 web tests passed.","timestamp":1790852184093,"segment":0} +{"run":11,"commit":"8a08156","metric":29257,"metrics":{"gzip_bytes":9701},"status":"discard","description":"Expose generated protocol descriptors individually with valid pure annotations and have web consume only its nine handlers. It saved just 140 bytes (29,397 -> 29,257) while significantly expanding generated/public wiring, so discard as poor complexity-to-size tradeoff.","timestamp":1790852239716,"segment":0} +{"run":12,"commit":"c952296","metric":28402,"metrics":{"gzip_bytes":9844},"status":"keep","description":"Dictionary-encode repeated protocol wire field names and numeric-tag generated rename traversal nodes. 29,397 -> 28,402 bytes; gzip moved from 9,750 to 9,844 bytes, while standard build and all 75 TypeScript protocol tests passed.","timestamp":1790852295606,"segment":0} +{"run":13,"commit":"90ca324","metric":27610,"metrics":{"gzip_bytes":9889},"status":"keep","description":"Encode the generated protocol rename spine as a compact printable string and reconstruct it once in the codec runtime, with generator bounds validation and a late-model regression test. 28,402 -> 27,610 bytes; gzip moved from 9,844 to 9,889 bytes, standard build passed, and all 76 TypeScript protocol tests passed.","timestamp":1790852475847,"segment":0} +{"run":14,"commit":"90ca324","metric":27610,"metrics":{"gzip_bytes":9899},"status":"discard","description":"Join the generated wire-field dictionary into one delimiter-separated string and split at startup. Oxc produced identical 27,610-byte JavaScript and gzip worsened by 10 bytes, so discard.","timestamp":1790852544996,"segment":0} +{"run":15,"commit":"3b1b1b8","metric":27545,"metrics":{"gzip_bytes":9825},"status":"keep","description":"Decode compact rename-map symbols arithmetically from ASCII code points instead of shipping the 92-character encoding alphabet. 27,610 -> 27,545 bytes and gzip 9,889 -> 9,825 bytes; standard build and all 76 TypeScript protocol tests passed.","timestamp":1790852584709,"segment":0} +{"run":16,"commit":"1f88f2c","metric":27498,"metrics":{"gzip_bytes":9811},"status":"keep","description":"Unify telemetry export success and exception handling so measurement restoration, backoff updates, and active-request cleanup share one path; strengthen the failure test to verify restored measurements retry during shutdown. 27,545 -> 27,498 bytes and gzip 9,825 -> 9,811 bytes; standard build and all 13 telemetry tests passed.","timestamp":1790852631831,"segment":0} +{"run":17,"commit":"2e04beb","metric":27450,"metrics":{"gzip_bytes":9795},"status":"keep","description":"Make telemetry flush async so immediate stopped, empty, and backoff branches return booleans directly instead of constructing explicit resolved promises. 27,498 -> 27,450 bytes and gzip 9,811 -> 9,795 bytes; standard build and all 13 telemetry tests passed.","timestamp":1790852656797,"segment":0} +{"run":18,"commit":"38dbef7","metric":27432,"metrics":{"gzip_bytes":9794},"status":"keep","description":"Apply shared telemetry product/platform attributes inside the counter recorder instead of wrapping all three counter call sites. 27,450 -> 27,432 bytes and gzip 9,795 -> 9,794 bytes; standard build and all 13 telemetry tests passed.","timestamp":1790852677317,"segment":0} +{"run":19,"commit":"38dbef7","metric":27433,"metrics":{"gzip_bytes":9785},"status":"discard","description":"Inline shared product/platform attributes into the histogram and counter recording paths to remove the attribute helper. JavaScript regressed by 1 byte (27,432 -> 27,433), despite gzip improving by 9 bytes, so discard on the primary metric.","timestamp":1790852697036,"segment":0} +{"run":20,"commit":"e1ef614","metric":27387,"metrics":{"gzip_bytes":9786},"status":"keep","description":"Represent protocol codec traversal direction as a boolean instead of repeated decode/encode string values. 27,432 -> 27,387 bytes and gzip 9,794 -> 9,786 bytes; standard build and all 76 TypeScript protocol tests passed.","timestamp":1790852775564,"segment":0} +{"run":21,"commit":"e1ef614","metric":27357,"metrics":{"gzip_bytes":9768},"status":"discard","description":"Replace per-object rename lookup Maps with nested scans over rename entries. It saved only 30 bytes (27,387 -> 27,357) and 18 gzip bytes while changing traversal from indexed lookup to O(fields × entries), so discard the performance risk for a marginal gain.","timestamp":1790852802013,"segment":0} +{"run":22,"commit":"0faf133","metric":27357,"metrics":{"gzip_bytes":9770},"status":"keep","description":"Call the shared navigation-duration recorder directly at its two success/failure sites instead of retaining one-use private wrappers. 27,387 -> 27,357 bytes and gzip 9,786 -> 9,770 bytes; standard build and 112 focused web protocol/window tests passed. Also refresh the autoresearch experiment summary.","timestamp":1790852857819,"segment":0} +{"run":23,"commit":"7a95f83","metric":27839,"metrics":{"gzip_bytes":9963},"status":"keep","description":"Rebase the retained optimizations onto current main while preserving newly added ProtocolValidationError exports, own-property required-field checks, nested string validation, and undefined normalization. Current-main validation increased the retained bundle from 27,357 to 27,839 bytes, still below the 27.6 KiB stretch target; standard build and all 89 TypeScript protocol tests passed.","timestamp":1790853409847,"segment":0} +{"run":24,"commit":"5c233b6","metric":27953,"metrics":{"gzip_bytes":9998},"status":"discard","description":"Size-only comparison for operator-required review fixes: cache reusable rename lookups, support escaped base-91 integers through MAX_SAFE_INTEGER, and extract a pure decoder with synthetic compatibility tests. 27,839 -> 27,953 JS bytes; 9,963 -> 9,998 gzip bytes; standard build and 117 protocol tests passed. Not a size improvement, hence discard classification; DO NOT revert the code: these are explicitly approved correctness/performance fixes, retained independently of the optimization loop.","timestamp":1790855820068,"segment":0} diff --git a/autoresearch.md b/autoresearch.md new file mode 100644 index 000000000..c53418d78 --- /dev/null +++ b/autoresearch.md @@ -0,0 +1,56 @@ +# Autoresearch: Reduce the web JavaScript bundle + +## Objective + +Minimize the production JavaScript shipped by `platforms/web` while preserving its public API, declarations, checkout behavior, accessibility, protocol compatibility, telemetry events, and telemetry delivery behavior. The target is at most 32 KiB of minified JavaScript, with 27.6 KiB as a stretch goal. + +## Metrics + +- **Primary**: `js_bytes` (bytes, lower is better), the sum of all runtime `.js` files emitted under `platforms/web/dist` by the standard production build. The current build emits only `dist/index.js`, so this is also its exact byte size. +- **Secondary**: `gzip_bytes`, the sum of each emitted runtime JavaScript file compressed independently with gzip level 9 and `mtime=0`. +- **Correctness**: every experiment must complete the standard production build. Retained production changes also require focused tests plus the web lint, test, build, package verification, and sample build checks. + +## How to Run + +Run `shadowenv exec --dir -- ./autoresearch.sh`. It emits `METRIC js_bytes=` and `METRIC gzip_bytes=`. + +## Files in Scope + +- `platforms/web/src/**/*.ts` and `platforms/web/src/**/*.css`: web component runtime and focused regression tests. +- `platforms/web/vite.config.ts`: production bundling and tree-shaking configuration. +- `telemetry/languages/typescript/src/**/*.ts`: shared telemetry client and OTLP serialization used by web and React Native. +- `telemetry/languages/typescript/test/**/*.ts`: telemetry regression coverage. +- `protocol/languages/typescript/src/**/*.ts`: shared protocol runtime, including generated artifacts when changed through their generator. +- Protocol generators and their tests when generated runtime changes are required. + +## Off Limits + +- Public API or declaration removals. +- Removing, disabling, sampling, or deferring telemetry or checkout functionality solely to reduce size. +- Externalizing runtime dependencies, adding a CDN, or adding runtime dependencies. +- Toolchain, lockfile, compression-method, source-map-only, documentation-only, declaration-only, or package-metadata-only size changes. +- Manual edits to generated files. + +## Constraints + +- Keep the package self-contained and count every shipped runtime JavaScript chunk. +- Preserve React Native behavior for shared telemetry or protocol changes. +- Keep production build and measurement methodology unchanged between experiments. +- Add or update focused regression coverage for retained production changes; do not weaken tests. +- Run every command through the repository's required `shadowenv exec` environment. +- Stop at 50 experiments or the worker's $50 spend limit. + +## What's Been Tried + +- Fresh `main` baseline: 42,204 JavaScript bytes and 11,490 deterministic gzip bytes. +- Retained: derive embedded method values from existing catalogs; 41,895 bytes. +- Retained: generate only wire names in the protocol rename map and derive camelCase names at runtime; 39,058 bytes. +- Retained: replace generated protocol model-name references with numeric model IDs; 37,029 bytes. +- Retained: enable Oxc code generation in Vite's ES-library minifier output; 29,397 bytes. +- Retained: dictionary-encode wire field names and numeric-tag rename traversal nodes; 28,402 bytes. +- Retained: encode the generated rename spine as a compact printable string, decode symbols arithmetically, and reconstruct it once at startup; 27,545 bytes. +- Retained: simplify telemetry export handling, async flush branches, and shared counter attributes; 27,432 bytes. +- Retained: represent protocol traversal direction as a boolean; 27,387 bytes and 9,786 gzip bytes. +- Discarded: individually exported protocol descriptors, alternate minifiers, delimiter-joined field dictionaries, attribute-helper inlining, and nested rename-entry scans. These were neutral, regressed size, added disproportionate complexity, or risked runtime performance. +- Rebased onto main while retaining its newer protocol validation and undefined normalization; 27,839 JavaScript bytes and 9,963 gzip bytes. +- Operator-approved review fixes: build reusable decode/encode lookup tables once per model, support escaped base-91 integers through `Number.MAX_SAFE_INTEGER`, and extract a pure decoder. Added synthetic child-variant, explicit-name, fixed-format, boundary, invalid-integer, and lookup-reuse tests (117 protocol tests total). Final size: 27,953 JavaScript bytes and 9,998 gzip bytes, still below both targets. Experiment #24 is classified as a size regression, but these required correctness/performance fixes are retained; do not revert them as a discarded optimization. diff --git a/autoresearch.sh b/autoresearch.sh new file mode 100755 index 000000000..c51a9114b --- /dev/null +++ b/autoresearch.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")" && pwd) +cd "$repo_root" + +git diff --check +/opt/dev/bin/dev web build >&2 + +python3 - <<'PY' +from gzip import compress +from pathlib import Path + +files = sorted(Path("platforms/web/dist").rglob("*.js")) +if not files: + raise SystemExit("production build emitted no runtime JavaScript") + +js_bytes = 0 +gzip_bytes = 0 +for path in files: + contents = path.read_bytes() + js_bytes += len(contents) + gzip_bytes += len(compress(contents, compresslevel=9, mtime=0)) + print(f"BUNDLE {path} bytes={len(contents)}", file=__import__("sys").stderr) + +print(f"METRIC js_bytes={js_bytes}") +print(f"METRIC gzip_bytes={gzip_bytes}") +PY diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index c53c1d2a3..1eb858159 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -586,14 +586,6 @@ export class ShopifyCheckout } } - #recordNavigationSuccess(): void { - this.#recordNavigationDuration("success"); - } - - #recordNavigationFailure(): void { - this.#recordNavigationDuration("failure"); - } - #recordNavigationDuration(result: "success" | "failure"): void { const startedAt = this.#navigationStartedAt; if (startedAt === undefined) return; @@ -822,7 +814,7 @@ export class ShopifyCheckout .on(Event.start, ({ params: { checkout } }) => { // Web cannot reliably observe cross-origin popup page-finish, so the // success duration ends at `ec.start`: checkout is loaded and interactive. - this.#recordNavigationSuccess(); + this.#recordNavigationDuration("success"); const snapshot = this.#recordCheckout(checkout); /** @ignore - Events are documented by the class @event tags. */ this.dispatchEvent(new ShopifyCheckoutStartEvent({ checkout: snapshot })); @@ -845,7 +837,7 @@ export class ShopifyCheckout this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error: this.#error })); // `ec.error` is terminal for the embedded session. Message severity is // payload detail for the checkout error, not a host-side recovery signal. - this.#recordNavigationFailure(); + this.#recordNavigationDuration("failure"); this.close(); }) .on(Event.fulfillmentChange, ({ params: { checkout } }) => { diff --git a/protocol/languages/typescript/src/generated/ProtocolCodecs.ts b/protocol/languages/typescript/src/generated/ProtocolCodecs.ts index 8fc6581ca..d266693b7 100644 --- a/protocol/languages/typescript/src/generated/ProtocolCodecs.ts +++ b/protocol/languages/typescript/src/generated/ProtocolCodecs.ts @@ -5,6 +5,7 @@ import { decodeProtocolObject, encodeProtocolObject, } from '../protocol_codec_runtime'; +import {AddressChangeResultModel, AuthRequestModel, AuthResultModel, CheckoutModel, CredentialResultModel, ErrorResponseModel, InstrumentsChangeResultModel, ReadyRequestModel, ReadyResultModel, WindowOpenRequestModel, WindowOpenResultModel} from './ProtocolRenameMap'; type AddressChangeResult = import('./Models').AddressChangeResult; type AuthRequest = import('./Models').AuthRequest; @@ -19,45 +20,45 @@ type WindowOpenRequest = import('./Models').WindowOpenRequest; type WindowOpenResult = import('./Models').WindowOpenResult; export function decodeAuthRequest(value: unknown): AuthRequest { - return decodeProtocolObject(value, 'AuthRequest') as unknown as AuthRequest; + return decodeProtocolObject(value, AuthRequestModel, 'AuthRequest') as unknown as AuthRequest; } export function decodeCheckout(value: unknown): Checkout { - return decodeProtocolObject(value, 'Checkout') as unknown as Checkout; + return decodeProtocolObject(value, CheckoutModel, 'Checkout') as unknown as Checkout; } export function decodeErrorResponse(value: unknown): ErrorResponse { - return decodeProtocolObject(value, 'ErrorResponse') as unknown as ErrorResponse; + return decodeProtocolObject(value, ErrorResponseModel, 'ErrorResponse') as unknown as ErrorResponse; } export function decodeReadyRequest(value: unknown): ReadyRequest { - return decodeProtocolObject(value, 'ReadyRequest') as unknown as ReadyRequest; + return decodeProtocolObject(value, ReadyRequestModel, 'ReadyRequest') as unknown as ReadyRequest; } export function decodeWindowOpenRequest(value: unknown): WindowOpenRequest { - return decodeProtocolObject(value, 'WindowOpenRequest') as unknown as WindowOpenRequest; + return decodeProtocolObject(value, WindowOpenRequestModel, 'WindowOpenRequest') as unknown as WindowOpenRequest; } export function encodeAddressChangeResult(value: AddressChangeResult): unknown { - return encodeProtocolObject(value, 'AddressChangeResult'); + return encodeProtocolObject(value, AddressChangeResultModel); } export function encodeAuthResult(value: AuthResult): unknown { - return encodeProtocolObject(value, 'AuthResult'); + return encodeProtocolObject(value, AuthResultModel); } export function encodeCredentialResult(value: CredentialResult): unknown { - return encodeProtocolObject(value, 'CredentialResult'); + return encodeProtocolObject(value, CredentialResultModel); } export function encodeInstrumentsChangeResult(value: InstrumentsChangeResult): unknown { - return encodeProtocolObject(value, 'InstrumentsChangeResult'); + return encodeProtocolObject(value, InstrumentsChangeResultModel); } export function encodeReadyResult(value: ReadyResult): unknown { - return encodeProtocolObject(value, 'ReadyResult'); + return encodeProtocolObject(value, ReadyResultModel); } export function encodeWindowOpenResult(value: WindowOpenResult): unknown { - return encodeProtocolObject(value, 'WindowOpenResult'); + return encodeProtocolObject(value, WindowOpenResultModel); } diff --git a/protocol/languages/typescript/src/generated/ProtocolNotifications.ts b/protocol/languages/typescript/src/generated/ProtocolNotifications.ts index e1f16202e..ba3c711ea 100644 --- a/protocol/languages/typescript/src/generated/ProtocolNotifications.ts +++ b/protocol/languages/typescript/src/generated/ProtocolNotifications.ts @@ -234,19 +234,6 @@ export const requestDescriptors = { }; export const embeddedCheckoutMethods: ReadonlySet = new Set([ - 'ec.ready', - 'ec.auth', - 'ec.error', - 'ec.start', - 'ec.complete', - 'ec.messages.change', - 'ec.line_items.change', - 'ec.buyer.change', - 'ec.totals.change', - 'ec.payment.change', - 'ec.payment.instruments_change_request', - 'ec.payment.credential_request', - 'ec.window.open_request', - 'ec.fulfillment.change', - 'ec.fulfillment.address_change_request', + ...Object.values(checkoutProtocolCatalog), + ...Object.values(checkoutProtocolRequestCatalog), ]); diff --git a/protocol/languages/typescript/src/generated/ProtocolRenameMap.ts b/protocol/languages/typescript/src/generated/ProtocolRenameMap.ts index 968cdbcbd..123521aff 100644 --- a/protocol/languages/typescript/src/generated/ProtocolRenameMap.ts +++ b/protocol/languages/typescript/src/generated/ProtocolRenameMap.ts @@ -6,12 +6,94 @@ // path needed to reach them. Extension keys (UCP additionalProperties) are // preserved by the walker's pass-through and are intentionally absent here. -export type RenameChild = - | ['r', string] - | ['a', RenameChild] - | ['m', RenameChild] - | ['u', ...RenameChild[]]; +export type {RenameChild, RenameEntry} from '../protocol_rename_map'; -export type RenameEntry = [string, string] | [string, string, RenameChild]; +export const AddressChangeCheckoutModel = 0; +export const AddressChangeResultModel = 1; +export const AdjustmentModel = 2; +export const AdjustmentLineItemModel = 3; +export const AdjustmentStatusModel = 4; +export const AppliedDiscountModel = 5; +export const AuthModel = 6; +export const AuthRequestModel = 7; +export const AuthResultModel = 8; +export const BuyerModel = 9; +export const CapabilityElementModel = 10; +export const CapabilityResponseSchemaModel = 11; +export const CheckoutModel = 12; +export const CheckoutDiscountsModel = 13; +export const CheckoutFulfillmentModel = 14; +export const CheckoutFulfillmentObjectModel = 15; +export const CheckoutStatusModel = 16; +export const CheckoutTotalModel = 17; +export const ConstraintExpressionModel = 18; +export const ConstraintPropertyModel = 19; +export const ContentTypeModel = 20; +export const ContextModel = 21; +export const CredentialCheckoutModel = 22; +export const CredentialResultModel = 23; +export const DescriptionModel = 24; +export const DiscountAllocationModel = 25; +export const DiscountMethodModel = 26; +export const EmbeddedColorSchemeModel = 27; +export const EmbeddedServiceModel = 28; +export const EmbeddedTransportConfigModel = 29; +export const ErrorResponseModel = 30; +export const ErrorResponseUcpModel = 31; +export const ErrorStatusModel = 32; +export const EventLineItemModel = 33; +export const ExpectationModel = 34; +export const ExpectationLineItemModel = 35; +export const FulfillmentModel = 36; +export const FulfillmentAvailableMethodModel = 37; +export const FulfillmentDestinationModel = 38; +export const FulfillmentEventModel = 39; +export const FulfillmentGroupModel = 40; +export const FulfillmentMethodModel = 41; +export const FulfillmentOptionModel = 42; +export const InstrumentsChangeCheckoutModel = 43; +export const InstrumentsChangePaymentModel = 44; +export const InstrumentsChangeResultModel = 45; +export const InstrumentsChangeResultUcpModel = 46; +export const ItemModel = 47; +export const LineModel = 48; +export const LineItemModel = 49; +export const LineItemQuantityModel = 50; +export const LineItemStatusModel = 51; +export const LineItemTotalModel = 52; +export const LinkModel = 53; +export const MeasureModel = 54; +export const MessageModel = 55; +export const MessageTypeModel = 56; +export const OrderModel = 57; +export const OrderConfirmationModel = 58; +export const OrderLineItemModel = 59; +export const PaymentModel = 60; +export const PaymentCredentialModel = 61; +export const PaymentHandlerAvailableInstrumentModel = 62; +export const PaymentHandlerElementModel = 63; +export const PaymentHandlerResponseSchemaModel = 64; +export const PaymentHandlerResponseSchemaAvailableInstrumentModel = 65; +export const PolicyModel = 66; +export const PostalAddressModel = 67; +export const PreferredPaymentHandlerModel = 68; +export const QuantityUnitModel = 69; +export const ReadyCheckoutModel = 70; +export const ReadyPaymentModel = 71; +export const ReadyRequestModel = 72; +export const ReadyResultModel = 73; +export const SelectedPaymentInstrumentModel = 74; +export const ServiceModel = 75; +export const ServiceResponseSchemaModel = 76; +export const SeverityModel = 77; +export const TransportModel = 78; +export const UcpCheckoutResponseSchemaModel = 79; +export const UcpCheckoutResponseSchemaStatusModel = 80; +export const UcpOrderResponseSchemaModel = 81; +export const UnitPriceModel = 82; +export const UpgradeModel = 83; +export const WindowOpenRequestModel = 84; +export const WindowOpenResultModel = 85; -export const renameMap: Record = {"AddressChangeCheckout":[["fulfillment","fulfillment",["r","CheckoutFulfillmentObject"]]],"AddressChangeResult":[["checkout","checkout",["r","AddressChangeCheckout"]],["ucp","ucp",["r","InstrumentsChangeResultUcp"]],["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]]],"Adjustment":[["line_items","lineItems",["a",["r","AdjustmentLineItem"]]],["occurred_at","occurredAt"],["totals","totals",["a",["r","LineItemTotal"]]]],"AdjustmentLineItem":[["measure","measure",["r","Measure"]]],"AuthResult":[["ucp","ucp",["r","InstrumentsChangeResultUcp"]],["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]]],"Buyer":[["first_name","firstName"],["last_name","lastName"],["phone_number","phoneNumber"]],"Checkout":[["buyer","buyer",["r","Buyer"]],["context","context",["r","Context"]],["continue_url","continueUrl"],["expires_at","expiresAt"],["fulfillment","fulfillment",["r","CheckoutFulfillment"]],["line_items","lineItems",["a",["r","LineItem"]]],["messages","messages",["a",["r","Message"]]],["order","order",["r","OrderConfirmation"]],["payment","payment",["r","Payment"]],["policies","policies",["a",["r","Policy"]]],["totals","totals",["a",["r","CheckoutTotal"]]],["ucp","ucp",["r","UcpCheckoutResponseSchema"]]],"CheckoutFulfillment":[["available_methods","availableMethods",["a",["r","FulfillmentAvailableMethod"]]],["methods","methods",["a",["r","FulfillmentMethod"]]]],"CheckoutFulfillmentObject":[["available_methods","availableMethods",["a",["r","FulfillmentAvailableMethod"]]],["methods","methods",["a",["r","FulfillmentMethod"]]]],"CheckoutTotal":[["display_text","displayText"],["lines","lines",["a",["r","Line"]]]],"Context":[["address_country","addressCountry"],["address_region","addressRegion"],["postal_code","postalCode"]],"CredentialCheckout":[["payment","payment",["r","Payment"]]],"CredentialResult":[["checkout","checkout",["r","CredentialCheckout"]],["ucp","ucp",["r","InstrumentsChangeResultUcp"]],["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]]],"EmbeddedTransportConfig":[["color_scheme","colorScheme"]],"ErrorResponse":[["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]],["ucp","ucp",["r","ErrorResponseUcp"]]],"ErrorResponseUcp":[["map_order","mapOrder"],["payment_handlers","paymentHandlers",["m",["a",["r","PaymentHandlerResponseSchema"]]]]],"Expectation":[["destination","destination",["r","PostalAddress"]],["fulfillable_on","fulfillableOn"],["line_items","lineItems"],["method_type","methodType"]],"Fulfillment":[["events","events",["a",["r","FulfillmentEvent"]]],["expectations","expectations",["a",["r","Expectation"]]]],"FulfillmentAvailableMethod":[["fulfillable_on","fulfillableOn"],["line_item_ids","lineItemIds"]],"FulfillmentDestination":[["address","address",["r","PostalAddress"]],["address_country","addressCountry"],["address_locality","addressLocality"],["address_region","addressRegion"],["extended_address","extendedAddress"],["first_name","firstName"],["last_name","lastName"],["phone_number","phoneNumber"],["postal_code","postalCode"],["street_address","streetAddress"]],"FulfillmentEvent":[["line_items","lineItems"],["occurred_at","occurredAt"],["tracking_number","trackingNumber"],["tracking_url","trackingUrl"]],"FulfillmentGroup":[["line_item_ids","lineItemIds"],["options","options",["a",["r","FulfillmentOption"]]],["selected_option_id","selectedOptionId"]],"FulfillmentMethod":[["destinations","destinations",["a",["r","FulfillmentDestination"]]],["groups","groups",["a",["r","FulfillmentGroup"]]],["line_item_ids","lineItemIds"],["selected_destination_id","selectedDestinationId"]],"FulfillmentOption":[["earliest_fulfillment_time","earliestFulfillmentTime"],["latest_fulfillment_time","latestFulfillmentTime"],["totals","totals",["a",["r","LineItemTotal"]]]],"InstrumentsChangeCheckout":[["payment","payment",["r","InstrumentsChangePayment"]]],"InstrumentsChangePayment":[["instruments","instruments",["a",["r","SelectedPaymentInstrument"]]],["selected_instrument_id","selectedInstrumentId"]],"InstrumentsChangeResult":[["checkout","checkout",["r","InstrumentsChangeCheckout"]],["ucp","ucp",["r","InstrumentsChangeResultUcp"]],["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]]],"InstrumentsChangeResultUcp":[["map_order","mapOrder"],["payment_handlers","paymentHandlers",["m",["a",["r","PaymentHandlerElement"]]]]],"Item":[["image_url","imageUrl"],["quantity_unit","quantityUnit",["r","QuantityUnit"]],["unit_price","unitPrice",["r","UnitPrice"]]],"Line":[["display_text","displayText"]],"LineItem":[["item","item",["r","Item"]],["parent_id","parentId"],["totals","totals",["a",["r","LineItemTotal"]]]],"LineItemTotal":[["display_text","displayText"]],"Measure":[["display_text","displayText"]],"Message":[["content_type","contentType"],["image_url","imageUrl"]],"Order":[["adjustments","adjustments",["a",["r","Adjustment"]]],["checkout_id","checkoutId"],["fulfillment","fulfillment",["r","Fulfillment"]],["line_items","lineItems",["a",["r","OrderLineItem"]]],["messages","messages",["a",["r","Message"]]],["permalink_url","permalinkUrl"],["policies","policies",["a",["r","Policy"]]],["totals","totals",["a",["r","CheckoutTotal"]]],["ucp","ucp",["r","UcpOrderResponseSchema"]]],"OrderConfirmation":[["permalink_url","permalinkUrl"]],"OrderLineItem":[["item","item",["r","Item"]],["parent_id","parentId"],["totals","totals",["a",["r","LineItemTotal"]]]],"Payment":[["instruments","instruments",["a",["r","SelectedPaymentInstrument"]]]],"PaymentHandlerElement":[["available_instruments","availableInstruments"]],"PaymentHandlerResponseSchema":[["available_instruments","availableInstruments"]],"Policy":[["applies_to","appliesTo"]],"PostalAddress":[["address_country","addressCountry"],["address_locality","addressLocality"],["address_region","addressRegion"],["extended_address","extendedAddress"],["first_name","firstName"],["last_name","lastName"],["phone_number","phoneNumber"],["postal_code","postalCode"],["street_address","streetAddress"]],"QuantityUnit":[["display_text","displayText"]],"ReadyCheckout":[["fulfillment","fulfillment",["r","CheckoutFulfillmentObject"]],["payment","payment",["r","ReadyPayment"]]],"ReadyPayment":[["instruments","instruments",["a",["r","SelectedPaymentInstrument"]]],["selected_instrument_id","selectedInstrumentId"]],"ReadyResult":[["checkout","checkout",["r","ReadyCheckout"]],["ucp","ucp",["r","InstrumentsChangeResultUcp"]],["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]]],"SelectedPaymentInstrument":[["billing_address","billingAddress",["r","PostalAddress"]],["handler_id","handlerId"]],"ServiceResponseSchema":[["config","config",["r","EmbeddedTransportConfig"]]],"UcpCheckoutResponseSchema":[["map_order","mapOrder"],["payment_handlers","paymentHandlers",["m",["a",["r","PaymentHandlerResponseSchema"]]]],["services","services",["m",["a",["r","ServiceResponseSchema"]]]]],"UcpOrderResponseSchema":[["map_order","mapOrder"],["payment_handlers","paymentHandlers",["m",["a",["r","PaymentHandlerResponseSchema"]]]]],"UnitPrice":[["measure","measure",["r","Measure"]],["reference","reference",["r","Measure"]]],"WindowOpenResult":[["ucp","ucp",["r","InstrumentsChangeResultUcp"]],["continue_url","continueUrl"],["messages","messages",["a",["r","Message"]]]]}; +export const renameFields = ["address","address_country","address_locality","address_region","adjustments","applies_to","available_instruments","available_methods","billing_address","buyer","checkout","checkout_id","color_scheme","config","content_type","context","continue_url","destination","destinations","display_text","earliest_fulfillment_time","events","expectations","expires_at","extended_address","first_name","fulfillable_on","fulfillment","groups","handler_id","image_url","instruments","item","last_name","latest_fulfillment_time","line_item_ids","line_items","lines","map_order","measure","messages","method_type","methods","occurred_at","options","order","parent_id","payment","payment_handlers","permalink_url","phone_number","policies","postal_code","quantity_unit","reference","selected_destination_id","selected_instrument_id","selected_option_id","services","street_address","totals","tracking_number","tracking_url","ucp","unit_price"]; +export const encodedRenameMap = "y#=#1&,#!b#P2!J$#Y%F$#%M!_$#V#I#X!!!!%b#P2!J$#Y%;!C!T!!!.+#+1#72!9!=#0F$#SJ$#YO#]Q#_U$#e_$#3b#r!$)$#GL$#K$)$#GL$#K!$5!G$#R!!!%#!%!V!#Q#_&,#8b#P2!J$#Y!!!!!#.!%2!J$#Yb#A$H!R%$#c!!&3#f$#JE!Y!%6!D!_$#V#Q#N$A$#mZ!&,#Mb#P2!J$#Y$H!R%$#b%@!W#hc#u#5!%B#QP!_$#V!!#5!!#5!$0!@!!+&$#$-!=#FF$#^J$#YS!U$#e_$#3b#t#S!%B#QP!_$#V#A$#m!!#(!#(!!#'!+#!$!%!:!;!C!T!V!^!!#5!$=#1Q#j$A$#mZ!!&,#ib#P2!J$#Y$*#f?!!#/#?!!%H!R%$#c]%$#o!$H!R%$#c$I#XX#X!!%b#P2!J$#Y"; diff --git a/protocol/languages/typescript/src/protocol_codec_runtime.d.ts b/protocol/languages/typescript/src/protocol_codec_runtime.d.ts index b974ac8ac..593138d69 100644 --- a/protocol/languages/typescript/src/protocol_codec_runtime.d.ts +++ b/protocol/languages/typescript/src/protocol_codec_runtime.d.ts @@ -6,6 +6,6 @@ export declare class ProtocolValidationError extends TypeError { readonly reason: ProtocolValidationReason; constructor(modelPath: string, reason: ProtocolValidationReason); } -export declare function decodeProtocolObject(value: unknown, modelName: string): JSONRecord; -export declare function encodeProtocolObject(value: unknown, modelName: string): unknown; +export declare function decodeProtocolObject(value: unknown, modelId: number, modelName: string): JSONRecord; +export declare function encodeProtocolObject(value: unknown, modelId: number): unknown; export {}; diff --git a/protocol/languages/typescript/src/protocol_codec_runtime.ts b/protocol/languages/typescript/src/protocol_codec_runtime.ts index 940336c98..73ec32b8b 100644 --- a/protocol/languages/typescript/src/protocol_codec_runtime.ts +++ b/protocol/languages/typescript/src/protocol_codec_runtime.ts @@ -1,8 +1,15 @@ -import {renameMap} from './generated/ProtocolRenameMap'; -import type {RenameChild, RenameEntry} from './generated/ProtocolRenameMap'; +import { + CheckoutModel, + ErrorResponseModel, + FulfillmentOptionModel, + ReadyRequestModel, + WindowOpenRequestModel, + encodedRenameMap, + renameFields, +} from './generated/ProtocolRenameMap'; +import {decodeRenameMap, type RenameChild} from './protocol_rename_map'; type JSONRecord = Record; -type Direction = 'decode' | 'encode'; export type ProtocolValidationReason = 'missing_required' | 'invalid_type'; @@ -26,16 +33,32 @@ export class ProtocolValidationError extends TypeError { } } -const REQUIRED_FIELDS: Record = { - Checkout: ['currency', 'id', 'line_items', 'links', 'status', 'totals', 'ucp'], - ErrorResponse: ['messages', 'ucp'], - ReadyRequest: ['delegate'], - WindowOpenRequest: ['url'], +type RenameLookup = Map; + +const renameMap = decodeRenameMap(encodedRenameMap).map(entries => { + const decode: RenameLookup = new Map(); + const encode: RenameLookup = new Map(); + for (const [fieldIndex, child] of entries) { + const protocolName = renameFields[fieldIndex]!; + const javascriptName = protocolName.replace(/_([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ); + decode.set(protocolName, [javascriptName, child]); + encode.set(javascriptName, [protocolName, child]); + } + return [decode, encode]; +}); + +const REQUIRED_FIELDS: Record = { + [CheckoutModel]: ['currency', 'id', 'line_items', 'links', 'status', 'totals', 'ucp'], + [ErrorResponseModel]: ['messages', 'ucp'], + [ReadyRequestModel]: ['delegate'], + [WindowOpenRequestModel]: ['url'], }; -const REQUIRED_STRING_FIELDS: Record = { - Checkout: ['currency', 'id'], - WindowOpenRequest: ['url'], +const REQUIRED_STRING_FIELDS: Record = { + [CheckoutModel]: ['currency', 'id'], + [WindowOpenRequestModel]: ['url'], }; const NESTED_REQUIRED_STRING_FIELDS: Record = { @@ -45,55 +68,47 @@ const NESTED_REQUIRED_STRING_FIELDS: Record = { export function decodeProtocolObject( value: unknown, + modelId: number, modelName: string, ): JSONRecord { const input = requireObject(value, modelName); - requireFields(input, REQUIRED_FIELDS[modelName] ?? [], modelName); - requireStringFields(input, REQUIRED_STRING_FIELDS[modelName] ?? [], modelName); + requireFields(input, REQUIRED_FIELDS[modelId] ?? [], modelName); + requireStringFields(input, REQUIRED_STRING_FIELDS[modelId] ?? [], modelName); requireNestedFields(input, modelName); - return walkObject(input, renameMap[modelName], 'decode', modelName) as JSONRecord; + return walkObject(input, renameMap[modelId], true, modelId) as JSONRecord; } -export function encodeProtocolObject( - value: unknown, - modelName: string, -): unknown { - return walkObject(value, renameMap[modelName], 'encode', modelName); +export function encodeProtocolObject(value: unknown, modelId: number): unknown { + return walkObject(value, renameMap[modelId], false, modelId); } function walkObject( value: unknown, - entries: RenameEntry[] | undefined, - direction: Direction, - modelName?: string, + entries: RenameLookup[] | undefined, + decode: boolean, + modelId?: number, ): unknown { const input = - direction === 'decode' && modelName === 'FulfillmentOption' + decode && modelId === FulfillmentOptionModel ? normalizeLegacyFulfillmentOptionDescription(value) : value; - if (!isObjectRecord(input) || (!entries && direction === 'encode')) { + if (!isObjectRecord(input) || (!entries && !decode)) { return input; } - const sourceIndex = direction === 'decode' ? 0 : 1; - const targetIndex = direction === 'decode' ? 1 : 0; - - const entryBySource = new Map(); - for (const entry of entries ?? []) { - entryBySource.set(entry[sourceIndex], entry); - } + const entryBySource = entries?.[decode ? 0 : 1]; const output: JSONRecord = {}; for (const [key, item] of Object.entries(input)) { // Structured clone preserves undefined-valued own properties whereas JSON // omits them. Only normalize objects being walked as protocol models; an // unknown extension value is passed through without changing its contents. - if (direction === 'decode' && item === undefined) { + if (decode && item === undefined) { continue; } - const entry = entryBySource.get(key); + const entry = entryBySource?.get(key); if (entry) { - output[entry[targetIndex]] = walkChild(item, entry[2], direction); + output[entry[0]] = walkChild(item, entry[1], decode); } else { output[key] = item; } @@ -104,36 +119,36 @@ function walkObject( function walkChild( value: unknown, child: RenameChild | undefined, - direction: Direction, + decode: boolean, ): unknown { if (!child) { return value; } switch (child[0]) { - case 'r': - return walkObject(value, renameMap[child[1]], direction, child[1]); - case 'a': + case 0: + return walkObject(value, renameMap[child[1]], decode, child[1]); + case 1: return Array.isArray(value) - ? value.map(item => walkChild(item, child[1], direction)) + ? value.map(item => walkChild(item, child[1], decode)) : value; - case 'm': + case 2: return isObjectRecord(value) - ? mapValues(value, child[1], direction) + ? mapValues(value, child[1], decode) : value; - case 'u': - return walkUnion(value, child.slice(1) as RenameChild[], direction); + case 3: + return walkUnion(value, child.slice(1) as RenameChild[], decode); } } function mapValues( value: JSONRecord, child: RenameChild, - direction: Direction, + decode: boolean, ): JSONRecord { const output: JSONRecord = {}; for (const [key, item] of Object.entries(value)) { - output[key] = walkChild(item, child, direction); + output[key] = walkChild(item, child, decode); } return output; } @@ -141,17 +156,17 @@ function mapValues( function walkUnion( value: unknown, members: RenameChild[], - direction: Direction, + decode: boolean, ): unknown { if (Array.isArray(value)) { - const arrayMember = members.find(member => member[0] === 'a'); - return arrayMember ? walkChild(value, arrayMember, direction) : value; + const arrayMember = members.find(member => member[0] === 1); + return arrayMember ? walkChild(value, arrayMember, decode) : value; } if (isObjectRecord(value)) { const objectMember = members.find( - member => member[0] === 'r' || member[0] === 'm', + member => member[0] === 0 || member[0] === 2, ); - return objectMember ? walkChild(value, objectMember, direction) : value; + return objectMember ? walkChild(value, objectMember, decode) : value; } return value; } diff --git a/protocol/languages/typescript/src/protocol_rename_map.d.ts b/protocol/languages/typescript/src/protocol_rename_map.d.ts new file mode 100644 index 000000000..c57bbb28d --- /dev/null +++ b/protocol/languages/typescript/src/protocol_rename_map.d.ts @@ -0,0 +1,3 @@ +export type RenameChild = [0, number] | [1, RenameChild] | [2, RenameChild] | [3, ...RenameChild[]]; +export type RenameEntry = [number] | [number, RenameChild]; +export declare function decodeRenameMap(encoded: string): RenameEntry[][]; diff --git a/protocol/languages/typescript/src/protocol_rename_map.ts b/protocol/languages/typescript/src/protocol_rename_map.ts new file mode 100644 index 000000000..660da0930 --- /dev/null +++ b/protocol/languages/typescript/src/protocol_rename_map.ts @@ -0,0 +1,33 @@ +export type RenameChild = + | [0, number] + | [1, RenameChild] + | [2, RenameChild] + | [3, ...RenameChild[]]; + +export type RenameEntry = [number] | [number, RenameChild]; + +export function decodeRenameMap(encoded: string): RenameEntry[][] { + let offset = 0; + const readSymbol = (): number => { + const code = encoded.charCodeAt(offset++); + return code - 33 - (code > 34 ? 1 : 0) - (code > 92 ? 1 : 0); + }; + const readValue = (): number => { + const value = readSymbol(); + return value === 91 ? readValue() * 91 + readSymbol() : value; + }; + const readChild = (type = readValue() - 1): RenameChild => { + if (type === 0) return [type, readValue()]; + if (type === 3) { + return [type, ...Array.from({length: readValue()}, () => readChild())]; + } + return [type as 1 | 2, readChild()]; + }; + return Array.from({length: readValue()}, () => + Array.from({length: readValue()}, () => { + const field = readValue(); + const type = readValue(); + return type === 0 ? [field] : [field, readChild(type - 1)]; + }), + ); +} diff --git a/protocol/languages/typescript/test/codec-runtime.test.ts b/protocol/languages/typescript/test/codec-runtime.test.ts index c008d0fbc..bb31c6cc3 100644 --- a/protocol/languages/typescript/test/codec-runtime.test.ts +++ b/protocol/languages/typescript/test/codec-runtime.test.ts @@ -1,6 +1,11 @@ import {expect, test} from 'vitest'; import {EmbeddedCheckoutProtocol} from '../src/embedded_checkout_protocol'; +import { + CheckoutModel, + InstrumentsChangeResultUcpModel, + PostalAddressModel, +} from '../src/generated/ProtocolRenameMap'; import { decodeProtocolObject, encodeProtocolObject, @@ -21,7 +26,7 @@ const wire = { }; test('camelizes known schema fields on decode', () => { - const decoded = decodeProtocolObject(wire, 'Checkout') as Record< + const decoded = decodeProtocolObject(wire, CheckoutModel, 'Checkout') as Record< string, unknown >; @@ -31,7 +36,7 @@ test('camelizes known schema fields on decode', () => { }); test('preserves unknown extension keys unchanged on decode', () => { - const decoded = decodeProtocolObject(wire, 'Checkout') as Record< + const decoded = decodeProtocolObject(wire, CheckoutModel, 'Checkout') as Record< string, unknown >; @@ -42,8 +47,8 @@ test('preserves unknown extension keys unchanged on decode', () => { }); test('round-trips extension keys through decode + encode', () => { - const decoded = decodeProtocolObject(wire, 'Checkout'); - const encoded = encodeProtocolObject(decoded, 'Checkout') as Record< + const decoded = decodeProtocolObject(wire, CheckoutModel, 'Checkout'); + const encoded = encodeProtocolObject(decoded, CheckoutModel) as Record< string, unknown >; @@ -56,6 +61,7 @@ test('round-trips extension keys through decode + encode', () => { test('renames fields inside array elements', () => { const decoded = decodeProtocolObject( {...wire, line_items: [{parent_id: 'parent-1', quantity: 2}]}, + CheckoutModel, 'Checkout', ) as Record>>; @@ -67,6 +73,7 @@ test('renames fields inside array elements', () => { test('renames fields inside map values', () => { const decoded = decodeProtocolObject( {payment_handlers: {stripe: [{available_instruments: ['card']}]}}, + InstrumentsChangeResultUcpModel, 'InstrumentsChangeResultUcp', ) as Record>>>; @@ -75,9 +82,19 @@ test('renames fields inside map values', () => { expect('available_instruments' in handler).toBe(false); }); +test('decodes compact rename entries for later model IDs', () => { + const decoded = decodeProtocolObject( + {street_address: '151 O’Connor St', address_country: 'CA'}, + PostalAddressModel, + 'PostalAddress', + ); + + expect(decoded).toEqual({streetAddress: '151 O’Connor St', addressCountry: 'CA'}); +}); + test('throws when a required string field is not a string', () => { expectValidationError( - () => decodeProtocolObject({...wire, currency: 123}, 'Checkout'), + () => decodeProtocolObject({...wire, currency: 123}, CheckoutModel, 'Checkout'), 'Checkout.currency', 'invalid_type', ); @@ -98,10 +115,10 @@ test('treats structured-cloned undefined properties as absent without changing e }; expect(Object.hasOwn(checkout, 'order')).toBe(true); - expect(decodeProtocolObject(checkout, 'Checkout')).toStrictEqual( - decodeProtocolObject(omitted, 'Checkout'), + expect(decodeProtocolObject(checkout, CheckoutModel, 'Checkout')).toStrictEqual( + decodeProtocolObject(omitted, CheckoutModel, 'Checkout'), ); - const decoded = decodeProtocolObject(checkout, 'Checkout'); + const decoded = decodeProtocolObject(checkout, CheckoutModel, 'Checkout'); expect(decoded).not.toHaveProperty('order'); expect(decoded).not.toHaveProperty('fulfillment'); expect(decoded.buyer).not.toHaveProperty('firstName'); @@ -118,8 +135,8 @@ test('decodes direct objects and JSON-serialized delivery the same way', () => { }); const serializedCheckout = JSON.parse(JSON.stringify(checkout)); - expect(decodeProtocolObject(checkout, 'Checkout')).toStrictEqual( - decodeProtocolObject(serializedCheckout, 'Checkout'), + expect(decodeProtocolObject(checkout, CheckoutModel, 'Checkout')).toStrictEqual( + decodeProtocolObject(serializedCheckout, CheckoutModel, 'Checkout'), ); }); @@ -134,6 +151,7 @@ test('preserves schema-valid null instead of treating it as absent', () => { methods: undefined, }, }, + CheckoutModel, 'Checkout', ); @@ -146,7 +164,12 @@ test.each(['currency', 'totals', 'ucp'])( 'rejects an undefined required Checkout.%s', field => { expectValidationError( - () => decodeProtocolObject({...wire, [field]: undefined}, 'Checkout'), + () => + decodeProtocolObject( + {...wire, [field]: undefined}, + CheckoutModel, + 'Checkout', + ), `Checkout.${field}`, 'missing_required', ); @@ -157,23 +180,27 @@ test('rejects undefined and malformed required fields inside a present order', ( const permalink_url = 'https://example.test/orders/order-1'; expectValidationError( - () => decodeProtocolObject( - {...wire, order: {id: undefined, permalink_url}}, - 'Checkout', - ), + () => + decodeProtocolObject( + {...wire, order: {id: undefined, permalink_url}}, + CheckoutModel, + 'Checkout', + ), 'Checkout.order.id', 'missing_required', ); expectValidationError( - () => decodeProtocolObject( - {...wire, order: {id: 'order-1', permalink_url: undefined}}, - 'Checkout', - ), + () => + decodeProtocolObject( + {...wire, order: {id: 'order-1', permalink_url: undefined}}, + CheckoutModel, + 'Checkout', + ), 'Checkout.order.permalink_url', 'missing_required', ); expectValidationError( - () => decodeProtocolObject({...wire, order: 123}, 'Checkout'), + () => decodeProtocolObject({...wire, order: 123}, CheckoutModel, 'Checkout'), 'Checkout.order', 'invalid_type', ); @@ -181,7 +208,12 @@ test('rejects undefined and malformed required fields inside a present order', ( test('requires the version of a present ucp object', () => { expectValidationError( - () => decodeProtocolObject({...wire, ucp: {version: undefined}}, 'Checkout'), + () => + decodeProtocolObject( + {...wire, ucp: {version: undefined}}, + CheckoutModel, + 'Checkout', + ), 'Checkout.ucp.version', 'missing_required', ); @@ -196,7 +228,7 @@ test.each([ ['ucp.version', {ucp: {version: {value: '2026-04-08'}}}], ])('rejects non-string Checkout.%s', (field, nested) => { expectValidationError( - () => decodeProtocolObject({...wire, ...nested}, 'Checkout'), + () => decodeProtocolObject({...wire, ...nested}, CheckoutModel, 'Checkout'), `Checkout.${field}`, 'invalid_type', ); @@ -205,7 +237,7 @@ test.each([ test('does not accept inherited required fields', () => { const inheritedCheckout = Object.create(wire) as Record; expectValidationError( - () => decodeProtocolObject(inheritedCheckout, 'Checkout'), + () => decodeProtocolObject(inheritedCheckout, CheckoutModel, 'Checkout'), 'Checkout.currency', 'missing_required', ); @@ -216,7 +248,12 @@ test('does not accept inherited required fields', () => { >; inheritedOrder.permalink_url = 'https://example.test/orders/order-1'; expectValidationError( - () => decodeProtocolObject({...wire, order: inheritedOrder}, 'Checkout'), + () => + decodeProtocolObject( + {...wire, order: inheritedOrder}, + CheckoutModel, + 'Checkout', + ), 'Checkout.order.id', 'missing_required', ); @@ -225,7 +262,12 @@ test('does not accept inherited required fields', () => { test('does not include a value in its validation error', () => { const malformedValue = {private_url: 'https://example.test/private/order-123'}; expectValidationError( - () => decodeProtocolObject({...wire, currency: malformedValue}, 'Checkout'), + () => + decodeProtocolObject( + {...wire, currency: malformedValue}, + CheckoutModel, + 'Checkout', + ), 'Checkout.currency', 'invalid_type', ); diff --git a/protocol/languages/typescript/test/payloads.test.ts b/protocol/languages/typescript/test/payloads.test.ts index 64091a9f1..297f167d9 100644 --- a/protocol/languages/typescript/test/payloads.test.ts +++ b/protocol/languages/typescript/test/payloads.test.ts @@ -4,6 +4,7 @@ import {describe, expect, test} from 'vitest'; import {EmbeddedCheckoutProtocol} from '../src/embedded_checkout_protocol'; import {Convert} from '../src/generated/Models'; import {decodeCheckout, decodeErrorResponse} from '../src/generated/ProtocolCodecs'; +import {CheckoutModel} from '../src/generated/ProtocolRenameMap'; import {encodeProtocolObject} from '../src/protocol_codec_runtime'; function fixture(name: string) { @@ -52,7 +53,7 @@ describe('pinned protocol payloads', () => { expect(location.address!.postalCode).toBe('10002'); } expect(checkout.fulfillment!.methods![0].destinations![0].type).toBe('shipping_address'); - expect(encodeProtocolObject(checkout, 'Checkout')).toEqual(wire); + expect(encodeProtocolObject(checkout, CheckoutModel)).toEqual(wire); expect(JSON.parse(Convert.checkoutToJson(converted))).toEqual(wire); expect(checkout.ucp.mapOrder).toEqual( customFulfillment ? {payment_handlers: ['com.example.wallet']} : undefined, diff --git a/protocol/languages/typescript/test/rename-map-encoding.test.ts b/protocol/languages/typescript/test/rename-map-encoding.test.ts new file mode 100644 index 000000000..d9b61fe2b --- /dev/null +++ b/protocol/languages/typescript/test/rename-map-encoding.test.ts @@ -0,0 +1,141 @@ +import {afterEach, expect, test, vi} from 'vitest'; + +import {encodeRenameMap} from '../../../scripts/generate_typescript_rename_map.mjs'; +import type {RenameEntry} from '../src/generated/ProtocolRenameMap'; +import {decodeRenameMap} from '../src/protocol_rename_map'; + +const fields = ['nested_value', 'reference_value', 'array_values', 'map_values', 'union_value', 'map_union_value']; +const entries: RenameEntry[][] = [ + [ + [1, [0, 1]], + [2, [1, [0, 1]]], + [3, [2, [0, 1]]], + [4, [3, [1, [0, 1]], [0, 1]]], + [5, [3, [2, [0, 1]], [1, [0, 1]]]], + ], + [[0]], +]; + +afterEach(() => { + vi.doUnmock('../src/generated/ProtocolRenameMap'); + vi.resetModules(); + vi.restoreAllMocks(); +}); + +test('decodes all child variants from generator output', () => { + const encoded = encodeRenameMap(entries); + expect(decodeRenameMap(encoded)).toEqual(entries); + expect(decodeRenameMap(encoded)).toEqual(entries); +}); + +test.each([0, 1, 89, 90, 91, 92, 182, 8280, 8281, Number.MAX_SAFE_INTEGER])( + 'preserves encoded integer %s', + value => { + const map: RenameEntry[][] = [[[value, [0, value]]]]; + expect(decodeRenameMap(encodeRenameMap(map))).toEqual(map); + }, +); + +test.each([ + [0, '!'], + [90, '}'], + [91, '~#!'], + [92, '~##'], + [8280, '~}}'], + [8281, '~~#!!'], +] as const)('uses the expected wire representation for %s', (value, symbol) => { + const map: RenameEntry[][] = [[[value]]]; + const encoded = `##${symbol}!`; + expect(encodeRenameMap(map)).toBe(encoded); + expect(decodeRenameMap(encoded)).toEqual(map); +}); + +test.each([91, 92, 182])('supports %s models and entries', count => { + const map: RenameEntry[][] = Array.from({length: count}, () => []); + map[count - 1] = Array.from({length: count}, (_, field) => [field]); + expect(decodeRenameMap(encodeRenameMap(map))).toEqual(map); +}); + +test.each([-1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid encoded integer %s', + value => { + expect(() => encodeRenameMap([[[value]]])).toThrow('Invalid rename map integer'); + }, +); + +test('renames synthetic references, arrays, maps and both union branches', async () => { + vi.doMock('../src/generated/ProtocolRenameMap', () => ({ + CheckoutModel: -1, + ErrorResponseModel: -2, + FulfillmentOptionModel: -3, + ReadyRequestModel: -4, + WindowOpenRequestModel: -5, + encodedRenameMap: encodeRenameMap(entries), + renameFields: fields, + })); + const {decodeProtocolObject, encodeProtocolObject} = await import('../src/protocol_codec_runtime'); + for (const arrayUnion of [false, true]) { + const wire = { + reference_value: {nested_value: 'reference'}, + array_values: [{nested_value: 'array'}], + map_values: {key: {nested_value: 'map'}}, + union_value: arrayUnion ? [{nested_value: 'union'}] : {nested_value: 'union'}, + map_union_value: {key: {nested_value: 'map union'}}, + extension_data: {nested_value: 'unchanged'}, + }; + const expected = { + referenceValue: {nestedValue: 'reference'}, + arrayValues: [{nestedValue: 'array'}], + mapValues: {key: {nestedValue: 'map'}}, + unionValue: arrayUnion ? [{nestedValue: 'union'}] : {nestedValue: 'union'}, + mapUnionValue: {key: {nestedValue: 'map union'}}, + extension_data: {nested_value: 'unchanged'}, + }; + expect(decodeProtocolObject(wire, 0, 'Synthetic')).toEqual(expected); + expect(encodeProtocolObject(expected, 0)).toEqual(wire); + } +}); + +test('renames fields through model and field IDs beyond the single-symbol range', async () => { + const largeMap: RenameEntry[][] = Array.from({length: 93}, () => []); + largeMap[0] = [[91, [0, 92]]]; + largeMap[92] = [[92]]; + vi.doMock('../src/generated/ProtocolRenameMap', () => ({ + CheckoutModel: -1, + ErrorResponseModel: -2, + FulfillmentOptionModel: -3, + ReadyRequestModel: -4, + WindowOpenRequestModel: -5, + encodedRenameMap: encodeRenameMap(largeMap), + renameFields: Array.from({length: 93}, (_, index) => `field_${index}_name`), + })); + const {decodeProtocolObject, encodeProtocolObject} = await import('../src/protocol_codec_runtime'); + const wire = {field_91_name: {field_92_name: 'value'}}; + const expected = {field_91Name: {field_92Name: 'value'}}; + expect(decodeProtocolObject(wire, 0, 'Synthetic')).toEqual(expected); + expect(encodeProtocolObject(expected, 0)).toEqual(wire); +}); + +test('reuses derived names across repeated objects and both directions', async () => { + vi.doMock('../src/generated/ProtocolRenameMap', () => ({ + CheckoutModel: -1, + ErrorResponseModel: -2, + FulfillmentOptionModel: -3, + ReadyRequestModel: -4, + WindowOpenRequestModel: -5, + encodedRenameMap: encodeRenameMap(entries), + renameFields: fields, + })); + const {decodeProtocolObject, encodeProtocolObject} = await import('../src/protocol_codec_runtime'); + const replace = vi.spyOn(String.prototype, 'replace'); + const wire = {array_values: Array.from({length: 100}, () => ({nested_value: 'value'}))}; + const decoded = decodeProtocolObject(wire, 0, 'Synthetic'); + const encoded = encodeProtocolObject(decoded, 0); + const repeated = decodeProtocolObject(wire, 0, 'Synthetic'); + const conversions = replace.mock.calls.length; + replace.mockRestore(); + expect(conversions).toBe(0); + expect(decoded).toEqual({arrayValues: Array.from({length: 100}, () => ({nestedValue: 'value'}))}); + expect(repeated).toEqual(decoded); + expect(encoded).toEqual(wire); +}); diff --git a/protocol/languages/typescript/test/spec-metadata.test.ts b/protocol/languages/typescript/test/spec-metadata.test.ts index 14c635ef3..8b2a81d7e 100644 --- a/protocol/languages/typescript/test/spec-metadata.test.ts +++ b/protocol/languages/typescript/test/spec-metadata.test.ts @@ -4,6 +4,7 @@ import {describe, test, expect} from 'vitest'; import { SPEC_VERSION, Delegations, + checkoutProtocolCatalog, checkoutProtocolRequestCatalog, embeddedCheckoutMethods, } from '../src/generated/ProtocolNotifications'; @@ -55,12 +56,10 @@ describe('spec metadata', () => { }); }); - test('embedded methods cover notifications and requests', () => { - expect(embeddedCheckoutMethods.has('ec.start')).toBe(true); - expect(embeddedCheckoutMethods.has('ec.ready')).toBe(true); - expect( - embeddedCheckoutMethods.has('ec.payment.instruments_change_request'), - ).toBe(true); - expect(embeddedCheckoutMethods.size).toBe(15); + test('embedded methods exactly cover notifications and requests', () => { + expect([...embeddedCheckoutMethods]).toEqual([ + ...Object.values(checkoutProtocolCatalog), + ...Object.values(checkoutProtocolRequestCatalog), + ]); }); }); diff --git a/protocol/scripts/generate_typescript_codecs.mjs b/protocol/scripts/generate_typescript_codecs.mjs index 9e2d779c3..123b80b79 100644 --- a/protocol/scripts/generate_typescript_codecs.mjs +++ b/protocol/scripts/generate_typescript_codecs.mjs @@ -38,13 +38,13 @@ function codecModels() { function renderDecodeFunction(modelName) { return `export function decode${modelName}(value: unknown): ${modelName} { - return decodeProtocolObject(value, '${modelName}') as unknown as ${modelName}; + return decodeProtocolObject(value, ${modelName}Model, '${modelName}') as unknown as ${modelName}; }`; } function renderEncodeFunction(modelName) { return `export function encode${modelName}(value: ${modelName}): unknown { - return encodeProtocolObject(value, '${modelName}'); + return encodeProtocolObject(value, ${modelName}Model); }`; } @@ -61,6 +61,7 @@ import { decodeProtocolObject, encodeProtocolObject, } from '../protocol_codec_runtime'; +import {${allModels.map(modelName => `${modelName}Model`).join(', ')}} from './ProtocolRenameMap'; ${typeAliases} diff --git a/protocol/scripts/generate_typescript_notifications.mjs b/protocol/scripts/generate_typescript_notifications.mjs index e5fa7d481..ced23312c 100644 --- a/protocol/scripts/generate_typescript_notifications.mjs +++ b/protocol/scripts/generate_typescript_notifications.mjs @@ -134,7 +134,6 @@ function modelImports(notifications, requests) { function renderModule(notifications, requests) { const typeNames = modelImports(notifications, requests); - const allMethods = EC_METHODS.map(entry => entry.method); const codecNames = new Set(); for (const notification of notifications) { codecNames.add(`decode${notification.typeName}`); @@ -283,7 +282,8 @@ ${requests }; export const embeddedCheckoutMethods: ReadonlySet = new Set([ -${allMethods.map(method => ` '${method}',`).join('\n')} + ...Object.values(checkoutProtocolCatalog), + ...Object.values(checkoutProtocolRequestCatalog), ]); `; } diff --git a/protocol/scripts/generate_typescript_rename_map.mjs b/protocol/scripts/generate_typescript_rename_map.mjs index 23b10298a..ee04420ae 100644 --- a/protocol/scripts/generate_typescript_rename_map.mjs +++ b/protocol/scripts/generate_typescript_rename_map.mjs @@ -142,7 +142,15 @@ function buildRenameMap(typeMap, contains) { if (prop.json === prop.js && !child) { continue; } - entries.push(child ? [prop.json, prop.js, child] : [prop.json, prop.js]); + const derivedName = prop.json.replace(/_([a-z])/g, (_, letter) => + letter.toUpperCase(), + ); + if (derivedName !== prop.js) { + throw new Error( + `Cannot derive TypeScript property ${prop.js} from ${prop.json}`, + ); + } + entries.push(child ? [prop.json, child] : [prop.json]); } if (entries.length > 0) { renameMap[name] = entries; @@ -151,15 +159,84 @@ function buildRenameMap(typeMap, contains) { return renameMap; } +function indexChild(child, indexes) { + switch (child[0]) { + case "r": + return [0, indexes[child[1]]]; + case "a": + return [1, indexChild(child[1], indexes)]; + case "m": + return [2, indexChild(child[1], indexes)]; + case "u": + return [3, ...child.slice(1).map(member => indexChild(member, indexes))]; + } +} + +const RENAME_ALPHABET = Array.from({length: 94}, (_, index) => + String.fromCharCode(index + 33), +) + .filter(character => character !== '"' && character !== "\\") + .join(""); + +export function encodeRenameMap(renameMap) { + const values = [renameMap.length]; + const encodeChild = child => { + const [type, ...members] = child; + values.push(type + 1); + if (type === 0) { + values.push(members[0]); + } else if (type === 3) { + values.push(members.length); + members.forEach(encodeChild); + } else { + encodeChild(members[0]); + } + }; + for (const entries of renameMap) { + values.push(entries.length); + for (const [field, child] of entries) { + values.push(field); + if (child) { + encodeChild(child); + } else { + values.push(0); + } + } + } + const encodeValue = value => { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid rename map integer ${value}`); + } + return value < 91 + ? RENAME_ALPHABET[value] + : RENAME_ALPHABET[91] + encodeValue(Math.floor(value / 91)) + RENAME_ALPHABET[value % 91]; + }; + return values.map(encodeValue).join(""); +} + async function main() { const typeMap = parseTypeMap(await fs.readFile(MODELS, "utf8")); const contains = computeContainsRename(typeMap); const renameMap = buildRenameMap(typeMap, contains); - const sorted = Object.fromEntries( - Object.keys(renameMap) - .sort() - .map(key => [key, renameMap[key]]), + const names = Object.keys(typeMap).sort(); + const indexes = Object.fromEntries(names.map((name, index) => [name, index])); + const fields = [ + ...new Set(Object.values(renameMap).flatMap(entries => entries.map(entry => entry[0]))), + ].sort(); + const fieldIndexes = Object.fromEntries( + fields.map((field, index) => [field, index]), + ); + const indexedMap = names.map(name => + (renameMap[name] ?? []).map(([wireName, child]) => + child + ? [fieldIndexes[wireName], indexChild(child, indexes)] + : [fieldIndexes[wireName]], + ), ); + const modelIds = names + .map(name => `export const ${name}Model = ${indexes[name]};`) + .join("\n"); + const encodedMap = encodeRenameMap(indexedMap); const body = `// This file is generated by protocol/scripts/generate_typescript_rename_map.mjs. // Do not edit directly. // @@ -168,17 +245,12 @@ async function main() { // path needed to reach them. Extension keys (UCP additionalProperties) are // preserved by the walker's pass-through and are intentionally absent here. -export type RenameChild = - | ['r', string] - | ['a', RenameChild] - | ['m', RenameChild] - | ['u', ...RenameChild[]]; +export type {RenameChild, RenameEntry} from '../protocol_rename_map'; -export type RenameEntry = [string, string] | [string, string, RenameChild]; +${modelIds} -export const renameMap: Record = ${JSON.stringify( - sorted, - )}; +export const renameFields = ${JSON.stringify(fields)}; +export const encodedRenameMap = ${JSON.stringify(encodedMap)}; `; await fs.writeFile(OUTPUT, body); const count = Object.values(renameMap).reduce( diff --git a/telemetry/languages/typescript/src/client.ts b/telemetry/languages/typescript/src/client.ts index 28dca3f27..a5a7e8c14 100644 --- a/telemetry/languages/typescript/src/client.ts +++ b/telemetry/languages/typescript/src/client.ts @@ -66,27 +66,27 @@ class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { } recordError(metric: TelemetryErrorMetric): void { - this.#recordCounter('checkout_kit_error', this.#attributes({ + this.#recordCounter('checkout_kit_error', { category: metric.category, stage: metric.stage, code: metric.code, retryable: metric.retryable, is_retry: metric.isRetry ?? false, - })); + }); } recordProtocolDecodeError(metric: TelemetryProtocolDecodeErrorMetric): void { - this.#recordCounter('checkout_kit_protocol_decode_error', this.#attributes({ + this.#recordCounter('checkout_kit_protocol_decode_error', { method: toProtocolMethod(metric.method), failure_type: metric.failureType, - })); + }); } recordNavigationRetry(metric: TelemetryNavigationRetryMetric): void { - this.#recordCounter('checkout_kit_navigation_retry', this.#attributes({ + this.#recordCounter('checkout_kit_navigation_retry', { reason: metric.reason, result: metric.result, - })); + }); } recordNavigationDuration(metric: TelemetryNavigationDurationMetric): void { @@ -104,8 +104,8 @@ class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { }); } - flush(options: FlushOptions = {}, ignoreBackoff = false): Promise { - if (this.#stopped) return Promise.resolve(false); + async flush(options: FlushOptions = {}, ignoreBackoff = false): Promise { + if (this.#stopped) return false; const inFlight = this.#flushInProgress; if (inFlight) { if (this.#measurements.length === 0) return inFlight; @@ -124,13 +124,13 @@ class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { }); } if (this.#measurements.length === 0) { - return Promise.resolve(true); + return true; } // A keepalive flush is a page-terminal moment (pagehide/unload): skipping // it because of backoff would silently drop the buffered measurements. const bypassBackoff = ignoreBackoff || options.keepalive === true; if (!bypassBackoff && Date.now() < this.#nextExportAllowedAtMs) { - return Promise.resolve(false); + return false; } const measurements = this.#measurements; this.#measurements = []; @@ -166,7 +166,7 @@ class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { this.#record({ type: 'counter', name, - attributes, + attributes: this.#attributes(attributes), timeUnixNano: this.#now(), }); } @@ -196,6 +196,7 @@ class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { if (options.keepalive !== true) { this.#activeRequest = controller; } + let succeeded = false; try { const response = await this.#fetch(this.#endpoint, { method: 'POST', @@ -210,21 +211,14 @@ class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { referrerPolicy: 'no-referrer', signal: controller.signal, }); - const succeeded = response.ok; - if (!this.#stopped) { - if (!succeeded) this.#restoreMeasurements(measurements); - this.#updateExportBackoff(succeeded); - } - return succeeded; - } catch { - if (!this.#stopped) { - this.#restoreMeasurements(measurements); - this.#updateExportBackoff(false); - } - return false; - } finally { - if (this.#activeRequest === controller) this.#activeRequest = undefined; + succeeded = response.ok; + } catch {} + if (!this.#stopped) { + if (!succeeded) this.#restoreMeasurements(measurements); + this.#updateExportBackoff(succeeded); } + if (this.#activeRequest === controller) this.#activeRequest = undefined; + return succeeded; } #updateExportBackoff(succeeded: boolean): void { diff --git a/telemetry/languages/typescript/test/client.test.ts b/telemetry/languages/typescript/test/client.test.ts index 3275cbcc5..73fb579cb 100644 --- a/telemetry/languages/typescript/test/client.test.ts +++ b/telemetry/languages/typescript/test/client.test.ts @@ -112,6 +112,9 @@ describe('CheckoutKitTelemetry', () => { await expect(telemetry.flush()).resolves.toBe(false); const body = JSON.parse(fetch.mock.calls[0]![1].body as string); expect(body.resourceMetrics[0].scopeMetrics[0].metrics).toHaveLength(1); + + await expect(telemetry.shutdown()).resolves.toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); }); it('clamps pending measurement capacity to one', async () => { From f8dd80d42d0e6f0110be0ad38f9f4d215f055edd Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Fri, 2 Oct 2026 09:16:57 +0100 Subject: [PATCH 2/3] Remove autoresearch artifacts from bundle optimization PR --- autoresearch.config.json | 1 - autoresearch.jsonl | 25 ------------------ autoresearch.md | 56 ---------------------------------------- autoresearch.sh | 28 -------------------- 4 files changed, 110 deletions(-) delete mode 100644 autoresearch.config.json delete mode 100644 autoresearch.jsonl delete mode 100644 autoresearch.md delete mode 100755 autoresearch.sh diff --git a/autoresearch.config.json b/autoresearch.config.json deleted file mode 100644 index eb4f1e608..000000000 --- a/autoresearch.config.json +++ /dev/null @@ -1 +0,0 @@ -{"maxIterations":50,"maxCostUsd":50} \ No newline at end of file diff --git a/autoresearch.jsonl b/autoresearch.jsonl deleted file mode 100644 index 7fc277a29..000000000 --- a/autoresearch.jsonl +++ /dev/null @@ -1,25 +0,0 @@ -{"type":"config","name":"Reduce Checkout Kit web production JavaScript","metricName":"js_bytes","metricUnit":"","bestDirection":"lower"} -{"run":1,"commit":"e82fffc","metric":0,"metrics":{"gzip_bytes":0},"status":"crash","description":"Baseline build could not start because platforms/web dependencies are not provisioned (vite not found).","timestamp":1790851500524,"segment":0} -{"run":2,"commit":"a821210","metric":42204,"metrics":{"gzip_bytes":11490},"status":"keep","description":"Fresh origin/main baseline after provisioning dependencies; standard web production build emitted one runtime file and passed declaration/manifest generation.","timestamp":1790851583601,"segment":0} -{"run":3,"commit":"9dd9782","metric":41895,"metrics":{"gzip_bytes":11458},"status":"keep","description":"Derive telemetry's protocol-method Set from the existing generated notification/request catalogs instead of emitting 15 duplicate wire-method strings. 42,204 -> 41,895 bytes; standard build and 75 TypeScript protocol tests passed.","timestamp":1790851672068,"segment":0} -{"run":4,"commit":"9dd9782","metric":0,"metrics":{"gzip_bytes":0},"status":"crash","description":"Expose individually tree-shakeable protocol descriptors and consume them directly in web. Build emitted 41,674 bytes but declaration generation reported stale protocol .d.ts exports, so the experiment is invalid until declarations are regenerated.","timestamp":1790851762175,"segment":0} -{"run":5,"commit":"9dd9782","metric":41914,"metrics":{"gzip_bytes":11411},"status":"discard","description":"Mark individually exported generated descriptors pure so Rollup can prune unused protocol request codecs. The result regressed the retained 41,895-byte bundle to 41,914 bytes, so discard the descriptor-export approach.","timestamp":1790851814046,"segment":0} -{"run":6,"commit":"bb01a98","metric":39058,"metrics":{"gzip_bytes":11139},"status":"keep","description":"Store only protocol wire names in the generated rename table and derive their camelCase TypeScript names during codec traversal, with codegen validation that every generated name is derivable. 41,895 -> 39,058 bytes; standard build and all 75 TypeScript protocol tests passed.","timestamp":1790851894845,"segment":0} -{"run":7,"commit":"bb01a98","metric":0,"metrics":{"gzip_bytes":0},"status":"crash","description":"Index generated protocol model references numerically, reducing the build to 37,029 bytes, but six internal codec tests still called the changed private runtime helper with model-name strings. Update those tests to use generated model IDs before evaluating.","timestamp":1790852006145,"segment":0} -{"run":8,"commit":"f79dfec0","metric":37029,"metrics":{"gzip_bytes":10900},"status":"keep","description":"Replace generated protocol model-name references and object keys with deterministic numeric model IDs while preserving labels for validation errors. 39,058 -> 37,029 bytes; standard build and all 75 TypeScript protocol tests passed.","timestamp":1790852061308,"segment":0} -{"run":9,"commit":"f79dfec","metric":71154,"metrics":{"gzip_bytes":13972},"status":"discard","description":"Explicitly use Vite's esbuild minifier to test whether it removes Rolldown region formatting. It produced 71,154 bytes versus 37,029, so keep the existing Oxc/default minifier.","timestamp":1790852129141,"segment":0} -{"run":10,"commit":"8a08156","metric":29397,"metrics":{"gzip_bytes":9750},"status":"keep","description":"Enable Oxc code generation in Rolldown's library output minifier. Vite 8 intentionally defaulted ES-library minification to compress/mangle without codegen, leaving formatting and region comments. 37,029 -> 29,397 bytes; standard build and all 302 web tests passed.","timestamp":1790852184093,"segment":0} -{"run":11,"commit":"8a08156","metric":29257,"metrics":{"gzip_bytes":9701},"status":"discard","description":"Expose generated protocol descriptors individually with valid pure annotations and have web consume only its nine handlers. It saved just 140 bytes (29,397 -> 29,257) while significantly expanding generated/public wiring, so discard as poor complexity-to-size tradeoff.","timestamp":1790852239716,"segment":0} -{"run":12,"commit":"c952296","metric":28402,"metrics":{"gzip_bytes":9844},"status":"keep","description":"Dictionary-encode repeated protocol wire field names and numeric-tag generated rename traversal nodes. 29,397 -> 28,402 bytes; gzip moved from 9,750 to 9,844 bytes, while standard build and all 75 TypeScript protocol tests passed.","timestamp":1790852295606,"segment":0} -{"run":13,"commit":"90ca324","metric":27610,"metrics":{"gzip_bytes":9889},"status":"keep","description":"Encode the generated protocol rename spine as a compact printable string and reconstruct it once in the codec runtime, with generator bounds validation and a late-model regression test. 28,402 -> 27,610 bytes; gzip moved from 9,844 to 9,889 bytes, standard build passed, and all 76 TypeScript protocol tests passed.","timestamp":1790852475847,"segment":0} -{"run":14,"commit":"90ca324","metric":27610,"metrics":{"gzip_bytes":9899},"status":"discard","description":"Join the generated wire-field dictionary into one delimiter-separated string and split at startup. Oxc produced identical 27,610-byte JavaScript and gzip worsened by 10 bytes, so discard.","timestamp":1790852544996,"segment":0} -{"run":15,"commit":"3b1b1b8","metric":27545,"metrics":{"gzip_bytes":9825},"status":"keep","description":"Decode compact rename-map symbols arithmetically from ASCII code points instead of shipping the 92-character encoding alphabet. 27,610 -> 27,545 bytes and gzip 9,889 -> 9,825 bytes; standard build and all 76 TypeScript protocol tests passed.","timestamp":1790852584709,"segment":0} -{"run":16,"commit":"1f88f2c","metric":27498,"metrics":{"gzip_bytes":9811},"status":"keep","description":"Unify telemetry export success and exception handling so measurement restoration, backoff updates, and active-request cleanup share one path; strengthen the failure test to verify restored measurements retry during shutdown. 27,545 -> 27,498 bytes and gzip 9,825 -> 9,811 bytes; standard build and all 13 telemetry tests passed.","timestamp":1790852631831,"segment":0} -{"run":17,"commit":"2e04beb","metric":27450,"metrics":{"gzip_bytes":9795},"status":"keep","description":"Make telemetry flush async so immediate stopped, empty, and backoff branches return booleans directly instead of constructing explicit resolved promises. 27,498 -> 27,450 bytes and gzip 9,811 -> 9,795 bytes; standard build and all 13 telemetry tests passed.","timestamp":1790852656797,"segment":0} -{"run":18,"commit":"38dbef7","metric":27432,"metrics":{"gzip_bytes":9794},"status":"keep","description":"Apply shared telemetry product/platform attributes inside the counter recorder instead of wrapping all three counter call sites. 27,450 -> 27,432 bytes and gzip 9,795 -> 9,794 bytes; standard build and all 13 telemetry tests passed.","timestamp":1790852677317,"segment":0} -{"run":19,"commit":"38dbef7","metric":27433,"metrics":{"gzip_bytes":9785},"status":"discard","description":"Inline shared product/platform attributes into the histogram and counter recording paths to remove the attribute helper. JavaScript regressed by 1 byte (27,432 -> 27,433), despite gzip improving by 9 bytes, so discard on the primary metric.","timestamp":1790852697036,"segment":0} -{"run":20,"commit":"e1ef614","metric":27387,"metrics":{"gzip_bytes":9786},"status":"keep","description":"Represent protocol codec traversal direction as a boolean instead of repeated decode/encode string values. 27,432 -> 27,387 bytes and gzip 9,794 -> 9,786 bytes; standard build and all 76 TypeScript protocol tests passed.","timestamp":1790852775564,"segment":0} -{"run":21,"commit":"e1ef614","metric":27357,"metrics":{"gzip_bytes":9768},"status":"discard","description":"Replace per-object rename lookup Maps with nested scans over rename entries. It saved only 30 bytes (27,387 -> 27,357) and 18 gzip bytes while changing traversal from indexed lookup to O(fields × entries), so discard the performance risk for a marginal gain.","timestamp":1790852802013,"segment":0} -{"run":22,"commit":"0faf133","metric":27357,"metrics":{"gzip_bytes":9770},"status":"keep","description":"Call the shared navigation-duration recorder directly at its two success/failure sites instead of retaining one-use private wrappers. 27,387 -> 27,357 bytes and gzip 9,786 -> 9,770 bytes; standard build and 112 focused web protocol/window tests passed. Also refresh the autoresearch experiment summary.","timestamp":1790852857819,"segment":0} -{"run":23,"commit":"7a95f83","metric":27839,"metrics":{"gzip_bytes":9963},"status":"keep","description":"Rebase the retained optimizations onto current main while preserving newly added ProtocolValidationError exports, own-property required-field checks, nested string validation, and undefined normalization. Current-main validation increased the retained bundle from 27,357 to 27,839 bytes, still below the 27.6 KiB stretch target; standard build and all 89 TypeScript protocol tests passed.","timestamp":1790853409847,"segment":0} -{"run":24,"commit":"5c233b6","metric":27953,"metrics":{"gzip_bytes":9998},"status":"discard","description":"Size-only comparison for operator-required review fixes: cache reusable rename lookups, support escaped base-91 integers through MAX_SAFE_INTEGER, and extract a pure decoder with synthetic compatibility tests. 27,839 -> 27,953 JS bytes; 9,963 -> 9,998 gzip bytes; standard build and 117 protocol tests passed. Not a size improvement, hence discard classification; DO NOT revert the code: these are explicitly approved correctness/performance fixes, retained independently of the optimization loop.","timestamp":1790855820068,"segment":0} diff --git a/autoresearch.md b/autoresearch.md deleted file mode 100644 index c53418d78..000000000 --- a/autoresearch.md +++ /dev/null @@ -1,56 +0,0 @@ -# Autoresearch: Reduce the web JavaScript bundle - -## Objective - -Minimize the production JavaScript shipped by `platforms/web` while preserving its public API, declarations, checkout behavior, accessibility, protocol compatibility, telemetry events, and telemetry delivery behavior. The target is at most 32 KiB of minified JavaScript, with 27.6 KiB as a stretch goal. - -## Metrics - -- **Primary**: `js_bytes` (bytes, lower is better), the sum of all runtime `.js` files emitted under `platforms/web/dist` by the standard production build. The current build emits only `dist/index.js`, so this is also its exact byte size. -- **Secondary**: `gzip_bytes`, the sum of each emitted runtime JavaScript file compressed independently with gzip level 9 and `mtime=0`. -- **Correctness**: every experiment must complete the standard production build. Retained production changes also require focused tests plus the web lint, test, build, package verification, and sample build checks. - -## How to Run - -Run `shadowenv exec --dir -- ./autoresearch.sh`. It emits `METRIC js_bytes=` and `METRIC gzip_bytes=`. - -## Files in Scope - -- `platforms/web/src/**/*.ts` and `platforms/web/src/**/*.css`: web component runtime and focused regression tests. -- `platforms/web/vite.config.ts`: production bundling and tree-shaking configuration. -- `telemetry/languages/typescript/src/**/*.ts`: shared telemetry client and OTLP serialization used by web and React Native. -- `telemetry/languages/typescript/test/**/*.ts`: telemetry regression coverage. -- `protocol/languages/typescript/src/**/*.ts`: shared protocol runtime, including generated artifacts when changed through their generator. -- Protocol generators and their tests when generated runtime changes are required. - -## Off Limits - -- Public API or declaration removals. -- Removing, disabling, sampling, or deferring telemetry or checkout functionality solely to reduce size. -- Externalizing runtime dependencies, adding a CDN, or adding runtime dependencies. -- Toolchain, lockfile, compression-method, source-map-only, documentation-only, declaration-only, or package-metadata-only size changes. -- Manual edits to generated files. - -## Constraints - -- Keep the package self-contained and count every shipped runtime JavaScript chunk. -- Preserve React Native behavior for shared telemetry or protocol changes. -- Keep production build and measurement methodology unchanged between experiments. -- Add or update focused regression coverage for retained production changes; do not weaken tests. -- Run every command through the repository's required `shadowenv exec` environment. -- Stop at 50 experiments or the worker's $50 spend limit. - -## What's Been Tried - -- Fresh `main` baseline: 42,204 JavaScript bytes and 11,490 deterministic gzip bytes. -- Retained: derive embedded method values from existing catalogs; 41,895 bytes. -- Retained: generate only wire names in the protocol rename map and derive camelCase names at runtime; 39,058 bytes. -- Retained: replace generated protocol model-name references with numeric model IDs; 37,029 bytes. -- Retained: enable Oxc code generation in Vite's ES-library minifier output; 29,397 bytes. -- Retained: dictionary-encode wire field names and numeric-tag rename traversal nodes; 28,402 bytes. -- Retained: encode the generated rename spine as a compact printable string, decode symbols arithmetically, and reconstruct it once at startup; 27,545 bytes. -- Retained: simplify telemetry export handling, async flush branches, and shared counter attributes; 27,432 bytes. -- Retained: represent protocol traversal direction as a boolean; 27,387 bytes and 9,786 gzip bytes. -- Discarded: individually exported protocol descriptors, alternate minifiers, delimiter-joined field dictionaries, attribute-helper inlining, and nested rename-entry scans. These were neutral, regressed size, added disproportionate complexity, or risked runtime performance. -- Rebased onto main while retaining its newer protocol validation and undefined normalization; 27,839 JavaScript bytes and 9,963 gzip bytes. -- Operator-approved review fixes: build reusable decode/encode lookup tables once per model, support escaped base-91 integers through `Number.MAX_SAFE_INTEGER`, and extract a pure decoder. Added synthetic child-variant, explicit-name, fixed-format, boundary, invalid-integer, and lookup-reuse tests (117 protocol tests total). Final size: 27,953 JavaScript bytes and 9,998 gzip bytes, still below both targets. Experiment #24 is classified as a size regression, but these required correctness/performance fixes are retained; do not revert them as a discarded optimization. diff --git a/autoresearch.sh b/autoresearch.sh deleted file mode 100755 index c51a9114b..000000000 --- a/autoresearch.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(cd "$(dirname "$0")" && pwd) -cd "$repo_root" - -git diff --check -/opt/dev/bin/dev web build >&2 - -python3 - <<'PY' -from gzip import compress -from pathlib import Path - -files = sorted(Path("platforms/web/dist").rglob("*.js")) -if not files: - raise SystemExit("production build emitted no runtime JavaScript") - -js_bytes = 0 -gzip_bytes = 0 -for path in files: - contents = path.read_bytes() - js_bytes += len(contents) - gzip_bytes += len(compress(contents, compresslevel=9, mtime=0)) - print(f"BUNDLE {path} bytes={len(contents)}", file=__import__("sys").stderr) - -print(f"METRIC js_bytes={js_bytes}") -print(f"METRIC gzip_bytes={gzip_bytes}") -PY From 893fc2c21b260e03f39186f01672c0229c07e974 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Fri, 2 Oct 2026 09:20:45 +0100 Subject: [PATCH 3/3] Assert every embedded protocol method explicitly --- .../typescript/test/spec-metadata.test.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/protocol/languages/typescript/test/spec-metadata.test.ts b/protocol/languages/typescript/test/spec-metadata.test.ts index 8b2a81d7e..6e8c3d6ea 100644 --- a/protocol/languages/typescript/test/spec-metadata.test.ts +++ b/protocol/languages/typescript/test/spec-metadata.test.ts @@ -4,7 +4,6 @@ import {describe, test, expect} from 'vitest'; import { SPEC_VERSION, Delegations, - checkoutProtocolCatalog, checkoutProtocolRequestCatalog, embeddedCheckoutMethods, } from '../src/generated/ProtocolNotifications'; @@ -57,9 +56,27 @@ describe('spec metadata', () => { }); test('embedded methods exactly cover notifications and requests', () => { - expect([...embeddedCheckoutMethods]).toEqual([ - ...Object.values(checkoutProtocolCatalog), - ...Object.values(checkoutProtocolRequestCatalog), - ]); + expect(embeddedCheckoutMethods.has('ec.ready')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.auth')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.error')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.start')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.complete')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.messages.change')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.line_items.change')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.buyer.change')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.totals.change')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.payment.change')).toBe(true); + expect( + embeddedCheckoutMethods.has('ec.payment.instruments_change_request'), + ).toBe(true); + expect( + embeddedCheckoutMethods.has('ec.payment.credential_request'), + ).toBe(true); + expect(embeddedCheckoutMethods.has('ec.window.open_request')).toBe(true); + expect(embeddedCheckoutMethods.has('ec.fulfillment.change')).toBe(true); + expect( + embeddedCheckoutMethods.has('ec.fulfillment.address_change_request'), + ).toBe(true); + expect(embeddedCheckoutMethods.size).toBe(15); }); });