Skip to content

Add a Go + React example of the Hosted Fields payment - #15

Merged
evsinev merged 4 commits into
mainfrom
feat/go-react
Sep 11, 2026
Merged

evsinev merged 4 commits into
mainfrom
feat/go-react

Conversation

@evsinev

@evsinev evsinev commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

An eleventh example: the same Hosted Fields payment with a React + TypeScript single-page
application in front of a Go server.

nextjs/ already answers "what does this look like in React". It does not answer "which half of
this runs where": there the server components, the client components and the edge middleware
share a language, a src/ tree and a framework, and nothing in the file listing says which is
which. This one is the same page written as a plain SPA in web/, in front of a server that is
go-js's near enough to diff — so the boundary is a directory line and an HTTP request, and the
only things that cross it are one generated <script> and two fetch calls.

Port 3010, prefix /hosted-fields-examples-go-react, e2e port 4022.

go-react/
  main.go config.go oauth.go paynet.go   server — stdlib only, no go.mod require block
  web/src/app/                           two pages: an HTML template and an entry module each
  web/src/shared/{config,api,lib,ui}/    browser — React 19, Rsbuild, Biome, Steiger

What it demonstrates that nextjs/ does not

  • It implements all seven rows of the server contract, config.js included. nextjs/ is
    exempt from two of them, because a React page gets its config as props.
  • BASE_PATH stays a runtime setting. output.assetPrefix is './' and the pages reach the
    server only through window.CONFIG.basePath, so one web/dist — and one released binary —
    serves under any prefix. next build bakes its basePath in, which is why that example's
    Dockerfile takes a build argument, its compose service repeats the value and its e2e entry
    rebuilds. None of that is here.
  • Nothing from the environment reaches the bundle. No PUBLIC_* inlining and no settings
    module imported at build time. process is not even nameable under web/src: tsconfig.json
    sets "types": [], and only tsconfig.node.json adds node, for rsbuild.config.ts — the
    one file in the project that runs in Node.
  • No inline <script> survives the build, so the Content-Security-Policy needs no nonce and
    the page needs no middleware to mint one. The visible cost is that the stored theme is applied
    by applyStoredTheme() at the top of each entry module rather than before first paint.
  • web/dist is compiled in with //go:embed, so the artefact is still one static binary — a
    React application included. Only web/dist/.gitkeep is committed, and cleanDistPath.keep
    stops a build from deleting it, so a fresh clone compiles.

The four React↔SDK rules are the same as nextjs/'s, because the hazard is React's rather than
the framework's: constant className, no children in the containers, one init() behind a ref
guard, setStyle() on every theme change.

One thing worth a second look

Both pages are build output and live in web/dist beside the bundle, so that directory is served
through an allowlist — styles.css and static/ — rather than by a file server: otherwise
{prefix}/result.html would serve the 3DS return page without the checksum check that
GET {prefix}/result performs.

The first version of that check read the path as it arrived, and {prefix}/static/%2e%2e/result.html
answered 200. The mux redirects a literal .. and leaves the escaped one alone, and the file
server resolves it anyway. The name is now path.Cleaned before it is checked, and
asset_test.go covers that case and three more like it.

The React code is a port, not a synced copy

scripts/sync-shared.sh gives this example only styles.css, as it does nextjs/. Neither app
imports or is synced from the other; shared/public/checkout.js is the original both were ported
from. So wording the payer reads — ERROR_COPY, STATUS_COPY — now exists three times, and both
CLAUDE.md files say to change all three together.

Checks run locally

  • ./scripts/sync-shared.sh && git diff --exit-code — clean
  • yarn lint (Biome + tsc over both projects + Steiger) and yarn build in go-react/web
  • gofmt -l . && go vet ./... && go test ./... && go build ./... — 4 tests, on the same
    cross-language OAuth and callback vectors as the other examples, plus the new allowlist one
  • npm run test:docker:go-react — 10 e2e specs, including the full 3DS round trip, which is also
    what exercises the shipped deploy/nginx.conf
  • by hand against a running server: the bare-prefix 301, a CSP with no unsafe-inline, 403
    on a forged result query, 404 on result.html and on every traversal spelling of it, and the
    same web/dist served under a different BASE_PATH without a rebuild
  • a tracked-files-only copy (web/dist holding just .gitkeep) compiles

@evsinev
evsinev merged commit 72e63c5 into main Sep 11, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant