diff --git a/package.json b/package.json index b9f81df..646e671 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "dev": "node --watch app.js", "build:css": "tailwindcss -i ./src/tailwind.css -o ./static/styles.css --minify", "watch:css": "tailwindcss -i ./src/tailwind.css -o ./static/styles.css --watch", - "test": "jest" + "test": "jest", + "postinstall": "node scripts/ensure-castle-umd.js" }, "dependencies": { "@castleio/castle-js": "^2.8.5", diff --git a/react/src/castle/CastleProvider.tsx b/react/src/castle/CastleProvider.tsx index 30fe18b..aa2ed76 100644 --- a/react/src/castle/CastleProvider.tsx +++ b/react/src/castle/CastleProvider.tsx @@ -27,6 +27,11 @@ interface CastleContextValue { const CastleContext = createContext(null); +interface CastleClient { + createRequestToken: () => PromiseLike; + custom: (params: CustomParams) => unknown; +} + interface CastleProviderProps { publishableKey?: string; children: ReactNode; @@ -40,10 +45,15 @@ interface CastleProviderProps { export function CastleProvider({ publishableKey, children }: CastleProviderProps) { const isConfigured = Boolean(publishableKey); const configuredRef = useRef(false); + const clientRef = useRef(null); useEffect(() => { if (!publishableKey || configuredRef.current) return; - configure({ pk: publishableKey }); + const configured = configure({ pk: publishableKey }) as CastleClient | void; + clientRef.current = + configured && typeof configured.createRequestToken === 'function' + ? configured + : { createRequestToken, custom }; configuredRef.current = true; }, [publishableKey]); @@ -53,14 +63,15 @@ export function CastleProvider({ publishableKey, children }: CastleProviderProps createRequestToken: async () => { if (!isConfigured) return ''; try { - return await createRequestToken(); + const client = clientRef.current; + return client ? await client.createRequestToken() : ''; } catch (err) { console.error('Castle.createRequestToken failed', err); return ''; } }, trackCustom: (params) => { - if (isConfigured) custom(params); + clientRef.current?.custom(params); }, }), [isConfigured], diff --git a/scripts/ensure-castle-umd.js b/scripts/ensure-castle-umd.js new file mode 100644 index 0000000..6bd1f7f --- /dev/null +++ b/scripts/ensure-castle-umd.js @@ -0,0 +1,15 @@ +const fs = require('fs'); +const path = require('path'); + +const dist = path.join(__dirname, '..', 'node_modules', '@castleio', 'castle-js', 'dist'); +const dest = path.join(dist, 'castle.umd.js'); +if (!fs.existsSync(dist) || fs.existsSync(dest)) { + process.exit(0); +} + +const source = fs.readdirSync(dist).find((name) => ( + name.startsWith('castle.') && name.endsWith('.js') && name !== 'castle.js' +)); +if (source) { + fs.copyFileSync(path.join(dist, source), dest); +} diff --git a/static/app.js b/static/app.js index 8b65cb0..4abba0f 100644 --- a/static/app.js +++ b/static/app.js @@ -17,9 +17,14 @@ async function postJSON(url, data) { // Resolve a Castle request token, falling back gracefully if the browser SDK // is unavailable (e.g. no publishable key configured). +function castleClient() { + return window.__castle || window.Castle; +} + function withRequestToken(callback) { - if (window.Castle && typeof Castle.createRequestToken === "function") { - Castle.createRequestToken() + var sdk = castleClient(); + if (sdk && typeof sdk.createRequestToken === "function") { + sdk.createRequestToken() .then(callback) .catch(function (err) { console.error("Castle.createRequestToken failed", err); diff --git a/test/app.test.js b/test/app.test.js index 89bc19c..22be5c2 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -62,7 +62,19 @@ describe('page routes', () => { expect(res.text).toContain('Your account'); // config for the React app is injected, not the global SDK chrome expect(res.text).toContain('window.CASTLE_ACCOUNT'); - expect(res.text).not.toContain('/vendor/castle-js/castle.browser.js'); + expect(res.text).not.toContain('/vendor/castle-js/castle.umd.js'); + }); + + test('GET /login loads the Castle browser SDK as a UMD', async () => { + const res = await request(app).get('/login'); + expect(res.status).toBe(200); + expect(res.text).toContain('/vendor/castle-js/castle.umd.js'); + }); + + test('GET /vendor/castle-js/castle.umd.js serves the npm install', async () => { + const res = await request(app).get('/vendor/castle-js/castle.umd.js'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/javascript/); }); test.each(['signup', 'password_reset', 'lists', 'privacy', 'webhooks'])( diff --git a/views/base.pug b/views/base.pug index 732a1c2..51e11a7 100644 --- a/views/base.pug +++ b/views/base.pug @@ -16,13 +16,21 @@ html(lang="en") //- The server-rendered pages use the global browser SDK directly. The //- React /account page bundles its own SDK instance, so it skips this. if !account - script(src="/vendor/castle-js/castle.browser.js") + //- The 3.x UMD build is named @castleio/castle-js, so seed module.exports as window.Castle first. + script. + if (!window.Castle) { + window.exports = window.exports || {}; + window.module = window.module || { exports: window.exports }; + window.Castle = window.module.exports; + } + script(src="/vendor/castle-js/castle.umd.js") //- Server-rendered config, read by the browser without string interpolation. script(type="application/json" id="castle-config")!= JSON.stringify({ pk: castle_pk || null, valid_username: valid_username || null, valid_password: valid_password || null, invalid_password: invalid_password || null }) script. window.CASTLE_DEMO = JSON.parse(document.getElementById('castle-config').textContent); + window.Castle = window.Castle || (window.module && window.module.exports) || window["@castleio/castle-js"]; if (window.Castle && window.CASTLE_DEMO.pk) { - Castle.configure({ pk: window.CASTLE_DEMO.pk }); + window.__castle = Castle.configure({ pk: window.CASTLE_DEMO.pk }) || window.Castle; } script(src="/static/app.js" defer)