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.
114 lines
4.5 KiB
TypeScript
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);
|
|
}
|