Files
Crussell/frontend/src/lib/square/square.ts
T
popertots 439fc16402 Fix sticky Square SDK rejection and clean up script element on failure
A load that resolved the script tag but failed to expose window.Square (or timed out) permanently cached a rejected promise, bricking card entry until reload. sdkPromise now resets and the injected script element is removed on every failure path so later calls retry fresh.
2026-08-22 00:34:49 +01:00

114 lines
4.5 KiB
TypeScript

// Env vars (frontend build-time, public — safe for the browser):
// VITE_SQUARE_APPLICATION_ID Square Web Payments application ID (client-side public)
// VITE_SQUARE_LOCATION_ID Square location ID
// VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' | 'mock' (optional;
// auto-derived from the application ID prefix when
// omitted). 'mock' is LOCAL-DEV ONLY: it renders a
// token-only mock card form (never a real Square.js
// iframe). It is additionally gated on the dev build
// (import.meta.env.DEV), so it can never activate in
// a production bundle even if the var is mis-set.
const APP_ID = (import.meta.env.VITE_SQUARE_APPLICATION_ID as string | undefined) ?? '';
const LOCATION_ID = (import.meta.env.VITE_SQUARE_LOCATION_ID as string | undefined) ?? '';
const SQUARE_ENV = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? '';
export interface SquareConfig {
appId: string;
locationId: string;
}
/** True when the frontend runs in local-dev mock mode: VITE_SQUARE_ENVIRONMENT === 'mock'
* AND the dev build (import.meta.env.DEV). The DEV gate makes the mock structurally
* impossible in any production bundle — even if the env var is mis-set at build time. */
export function isSquareMock(): boolean {
return SQUARE_ENV === 'mock' && import.meta.env.DEV;
}
/** True when a card form can be shown: real Square credentials OR local-dev mock mode. */
export function isSquareConfigured(): boolean {
return isSquareMock() || (APP_ID !== '' && LOCATION_ID !== '');
}
export function getSquareConfig(): SquareConfig | null {
if (!isSquareConfigured()) return null;
return { appId: APP_ID, locationId: LOCATION_ID };
}
// Pinned SDK version: Square.js is loaded from the /v1/ path, which is
// Square's stable major version. Square does not publish SRI hashes, so no
// integrity attribute can be set — the /v1/ version pin is the supply-chain
// control (S-3). Monitor Square's release notes before major bumps.
const SQUARE_SDK_VERSION = 'v1';
function sdkUrl(): string {
const env = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? '';
const isSandbox = env === 'sandbox' || (APP_ID !== '' && APP_ID.startsWith('sandbox-'));
return isSandbox
? `https://sandbox.web.squarecdn.com/${SQUARE_SDK_VERSION}/square.js`
: `https://web.squarecdn.com/${SQUARE_SDK_VERSION}/square.js`;
}
let sdkPromise: Promise<unknown> | null = null;
/**
* Loads the Square.js script once and resolves with the global `Square` object.
* The promise is cached so concurrent card forms share a single script load.
*/
export function loadSquareSdk(): Promise<unknown> {
if (typeof window === 'undefined') {
return Promise.reject(new Error('Square SDK requires a browser environment'));
}
const win = window as unknown as { Square?: unknown };
if (win.Square) {
return Promise.resolve(win.Square);
}
if (sdkPromise) return sdkPromise;
sdkPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = sdkUrl();
script.async = true;
script.dataset.squareSdk = 'true';
// Non-sticky-rejection invariant: a FAILED load attempt must never poison
// the cached promise. Concurrent callers during one attempt share the same
// in-flight promise (good — single script load), but a REJECTED promise is
// cleared here so the next getSquarePayments() call starts a fresh attempt
// instead of returning the same error forever. The dead <script> is also
// detached so a retry injects a clean element.
const fail = (error: Error) => {
script.remove();
sdkPromise = null;
reject(error);
};
script.onload = () => {
if (win.Square) {
resolve(win.Square);
} else {
fail(new Error('Square.js loaded but the Square global is missing'));
}
};
script.onerror = () => {
fail(new Error('Failed to load Square Web Payments SDK'));
};
document.head.appendChild(script);
});
return sdkPromise;
}
/** Returns `Square.payments(appId, locationId)` once the SDK is loaded. */
export async function getSquarePayments(): Promise<unknown> {
const config = getSquareConfig();
if (!config) {
throw new Error(
'Square is not configured — set VITE_SQUARE_APPLICATION_ID and VITE_SQUARE_LOCATION_ID'
);
}
const Square = (await loadSquareSdk()) as {
payments: (appId: string, locationId: string) => unknown;
};
return Square.payments(config.appId, config.locationId);
}