punchout-simulator — Architecture

Components, data model, and message flows of the cXML PunchOut simulator · 2026-10-22

v1.2 — October 2026

Overview

punchout-simulator is a single-process developer tool for testing cXML PunchOut integrations. It plays the part of a virtual counterparty: it can behave as a virtual buyer that drives a real supplier's catalog (Mode A), or as a virtual supplier that serves a mock catalog to a real procurement platform (Mode B). Either way, the operator works against one honest, inspectable end of a PunchOut conversation while the other end is the real system under test.

The whole tool ships as one npm package. A Node process (Hono HTTP server) serves both a JSON control-plane API and a built single-page application from the same origin; the SPA is a React + Vite + Monaco workspace for composing, sending, and inspecting cXML documents.

Note

This document describes the technical architecture. For the business context — what PunchOut is and where this tool sits in a procurement workflow — see the PunchOut business primer. For day-to-day usage (sessions, operations, profiles, product lists), see the Operations & usage guide.

Design constraints

The architecture is shaped by a few deliberate constraints:

System context

At runtime the simulator is one server that faces two directions. Toward the operator it serves the SPA and the control-plane API. Toward the network it either calls out to a real supplier (Mode A) or receives calls from a real buyer (Mode B).

punchout-simulator — system contextpunchout-simulator(single Node process)SPA workspace(React + Monaco)Control-plane API(/api)Mode A driver(virtual buyer)Mode B mock supplier(/sim)data/(config.json + sessions/*.jsonl)IntegrationengineerReal suppliercatalog endpointReal procurementplatform (Ariba/Coupa...)browser (loopback)JSON over same originread / writePunchOutSetupRequest / OrderRequest(n/a)PunchOutSetupRequestbrowser shops catalogPunchOutOrderMessage (cart return)

Fig. 1: The simulator faces the operator (SPA + API) and the network (Mode A calls out to a real supplier; Mode B is called in by a real buyer).

Component architecture

Internally the server is a set of Hono route modules over a small cXML core and a file-backed store. The SPA is a flat React app talking to those routes through a single typed api client.

Component architectureSPA (src/web)Server (src/server)RoutescXML core (cxml/)StoreApp.tsxworkspace shellBuyerFlow / CartViewSessionList / editorsapi.tstyped clientparties / connectionsprofiles / productsflow.tsMode A driversim.tsMode B mock supplierpunchout-return.tsMode A cart callbackdata.ts / stream.tssessions + SSEbackup.tsconfig export / importbuild.tstemplate emitparse.tsfast-xml-parservalidate.tsfield-level checksmultipart.tsmultipart/relatedtypes.ts / presetsconfig.tslowdb entitieslog.tsJSONL sessionsattachments.tscart-store.tsin-memory cartsusage.tsdisk budget

Fig. 2: Route modules sit over a shared cXML core (build / parse / validate / multipart) and a file-backed store.

Two SPA conveniences worth knowing about architecturally: every cXML field is rendered through a shared CxmlEditor component that carries a Copy + Prettify toolbar (prettify is local and display-only on read-only fields), and a session's flow can be exported as a PDF — flow-pdf.ts renders a self-contained print-ready HTML report (validation results, HTTP statuses, attachments, timestamps) and hands it to the browser's native print-to-PDF, so the tool takes no PDF library dependency.

The cXML core

The cxml/ package is protocol logic with no HTTP or storage knowledge:

Module Responsibility
build.ts Emits cXML documents from typed inputs using template literals. Handles PunchOutSetupRequest, OrderRequest, address/contact blocks, classification blocks, and line-item totals (returns 0 for mixed-currency carts).
parse.ts Parses inbound cXML with fast-xml-parser. XXE is rejected.
validate.ts Bidirectional, field-level validation against expectations: credentials (only on header-bearing docs), single-currency, address completeness, operation/items coherence. Produces typed issues (error / warning).
multipart.ts Hand-assembles and parses multipart/related for OrderRequest attachments.
types.ts + *-presets.ts The shared domain types and the built-in presets. Profile presets ship for Generic, SAP Ariba, Coupa, Jaggaer (two UserAgent variants: JAGGAER and legacy SciQuest), Oracle iProcurement, SAP SRM / Business Network, and Workday — one source of truth for both the rows seeded into config.json and the in-memory fallback.

The store

State lives in a single data/ directory:

Data model

The configuration is normalized into independent entities so the same party or product set can be reused across many integrations. A Connection is the unit under test: it binds one Buyer to one Supplier, fixes which side the tool simulates (mode), and carries the pair-specific credentials and deployment options.

Configuration data modelBuyeridnameidentity : CredentialprofileId : -> ProfileshipTo / billTo : Addresscontact : ContactSupplieridnameidentity : CredentialpunchoutUrl / orderUrlproductListIds : -> ProductList[]allowMixedCurrencyConnectionidbuyerId : -> BuyersupplierId : -> Suppliermode : A | BsharedSecretsenderIdentity : CredentialdeploymentModeattachmentEncodingProfileidnameplatformcXML version / quirksaddressModeshipToInSetup / contactInSetupextrinsicsProductListidnameitems : CatalogItem[]builtin«JSONL log»SessionsessionId (BuyerCookie)records[] (append-only)operation

Fig. 3: Buyer, Supplier, Connection, Profile and ProductList are normalized config entities; Sessions are append-only JSONL logs anchored to a Connection.

Tip

Endpoints (punchoutUrl, orderUrl) are intrinsic to the Supplier — they are the same for every buyer that talks to it, so they live on the Supplier, never per-Connection. Pair-specific data (credentials, deployment mode) lives on the Connection.

Message flows

Mode A — virtual buyer drives a real supplier

In Mode A the operator composes and sends the buyer-side documents; the real supplier responds. The cart comes back through the browser to the tool's own /punchout callback, which parses it and files it under the session.

Mode A — virtual buyer drives a real supplierOperatorSPAflow.tsvalidate.tsReal supplier.punchoutsession logOperatorSPAflow.ts(/api)validate.tsReal supplier/punchoutcallbacksession logOperatorSPAflow.ts(/api)validate.tsReal supplier/punchoutcallbacksession logNew session (Connection + operation)POST /:id/setup/previewbuilt PunchOutSetupRequest (Monaco)opt[validate before send]ValidatePOST /:id/validatecheck requestissues (error/warning)SendPOST /:id/setupPunchOutSetupRequestPunchOutSetupResponse (StartPage URL)append request + responseStartPage URLopen StartPage, shop in browserbrowser auto-POSTs PunchOutOrderMessage(BrowserFormPost = /punchout)parse cart, match BuyerCookieappend inbound cartpoll / SSE -> cart appears in sessionbuild & send OrderRequestPOST /:id/order (optionally multipart)OrderRequestOrderResponse (Status)append

Fig. 4: Mode A — the tool sends buyer documents, the real supplier responds, and the cart returns through the /punchout callback.

Mode B — virtual supplier serves a mock catalog

In Mode B a real procurement platform calls the tool. The tool answers the PunchOutSetupRequest with a StartPage, serves a themed HTML catalog at /sim/:supplierId/*, and when the operator checks out it builds the PunchOutOrderMessage and auto-POSTs it back to the buyer's BrowserFormPost URL. The mock supplier also honours the setup operation: for edit it re-reads the carried ItemOut from the session log and pre-fills the catalog quantities (items it doesn't list show as extra rows); for inspect it renders the carried item(s) read-only and returns them unchanged.

Mode B — virtual supplier serves a mock catalogReal buyerBrowsersim.ts ..sim.validate.tssession logcart-storeReal buyer(procurement platform)Browsersim.ts (/sim)validate.tssession logcart-storeReal buyer(procurement platform)Browsersim.ts (/sim)validate.tssession logcart-storePOST PunchOutSetupRequest(credentials, BrowserFormPost URL)validate inbound requestappend requestPunchOutSetupResponse(StartPage = /sim/:id/catalog?cookie=...)redirect to StartPageGET catalog (themed HTML)build cart (fractional qty allowed per item)POST /sim/:id/checkout (requires cookie)store cart for sessionbuild PunchOutOrderMessageappend cartHTML page auto-submitting form(action = BrowserFormPost)POST PunchOutOrderMessage

Fig. 5: Mode B — the tool answers the buyer's setup, serves a catalog, and posts the cart back through the browser to the buyer's form-post URL.

Important

/sim/:id/checkout requires a BuyerCookie. A cookie-less checkout is rejected with 400 and creates no session. This guards against a phantom session being minted on every stray request — the catalog must be opened from a real PunchOutSetupResponse StartPage for a checkout to count.

Sessions

A session is one PunchOut conversation, identified by its BuyerCookie (used verbatim as the sessionId). It is the primary unit the operator works with: every request and response in the conversation is appended to that session's JSONL log, and the SPA renders a per-session message log rather than a global firehose.

Session lifecycleCreatedSetupShoppingCartReturnedOrderedHistoricalResumedDeletedNew session(Connection + operation)send PunchOutSetupRequestStartPage openedPunchOutOrderMessage receivedOrderRequest sentreload toolresume (Mode A)delete session

Fig. 6: A session moves from setup through shopping and cart return to order; historical Mode A sessions can be resumed, and any session can be deleted.

The operation attribute (create / edit / inspect) is captured from the PunchOutSetupRequest and shown on the session row. It drives what the outbound document carries — see the Operations & usage guide for the business meaning of each.

Security posture

The tool is built to be safe to run locally and to expose deliberately. It has two surfaces with different rules: the control plane (/api/*, the SPA) and the inbound surface (/sim/*, /punchout/*), which must stay open because real buyer systems and supplier pages post to it.

Control plane

Inbound surface

Outbound calls (Mode A to a supplier) allow only http(s), reject credentials embedded in the URL, don't follow redirects to other hosts, time out after 30 s, and stop reading a response past 25 MB. Private and loopback targets are deliberately allowed — pointing at a supplier on localhost or staging is the main use case; who may configure those URLs is controlled by the API rules above.

Outbound networking and corporate proxies

Node's global fetch (undici) does not honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY on its own — unlike curl or most CLIs. On a locked-down corporate network a direct connection to an external supplier is silently black-holed, so a Mode-A send would hang into the abort timeout. The proxy subsystem closes that gap in two layers:

The combined result is a three-state ProxyStatus — env (a proxy is in effect), none (direct), or system-ignored (the OS has a proxy this tool will not use, with a ready-to-paste HTTPS_PROXY= suggestion when the concrete host is known). It is printed as the startup banner's proxy: lines and served over /api/runtime, where the SPA header renders it as a proxy badge — visible even when the banner has scrolled away or the tool runs as a service. The operational guidance lives in the Operations & usage guide.

Build and distribution

Aspect Detail
Language TypeScript, ESM, single package
Server Hono on @hono/node-server
SPA React + Vite + Monaco editor
Dev npm run dev — Vite (:5173) + tsx watch server concurrently
Build vite build (SPA) + tsc (server) into dist/
Publish Only dist/ is published; bin → dist/server/cli.js
Run npx punchout-simulator (loopback, opens browser, seeds demo data)

CI publishes with npm publish --provenance (SLSA) on GitHub Release.