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..6e8c3d6ea 100644 --- a/protocol/languages/typescript/test/spec-metadata.test.ts +++ b/protocol/languages/typescript/test/spec-metadata.test.ts @@ -55,12 +55,28 @@ describe('spec metadata', () => { }); }); - test('embedded methods cover notifications and requests', () => { - expect(embeddedCheckoutMethods.has('ec.start')).toBe(true); + test('embedded methods exactly cover notifications and requests', () => { 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); }); }); 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 () => {