From 0f627d9b357a50f6b2ab34e1f35cc070908ece6c Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 7 Sep 2026 18:05:47 +0200 Subject: [PATCH 1/2] Load the Castle browser SDK as a UMD from npm. --- app.js | 25 +++++++++++++++++++++++++ react/src/castle/CastleProvider.tsx | 17 ++++++++++++++--- static/app.js | 9 +++++++-- test/app.test.js | 14 +++++++++++++- views/base.pug | 12 ++++++++++-- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app.js b/app.js index 59589db..2ed7afa 100644 --- a/app.js +++ b/app.js @@ -47,6 +47,25 @@ function errorResult(err) { return { error: err instanceof APIError ? err.message : String(err) }; } +// 2.x ships castle.browser.js; 3.x ships castle.umd.js. The HTML always requests castle.umd.js. +function resolveCastleJsFile(dir, filename) { + const aliases = { + 'castle.umd.js': ['castle.umd.js', 'castle.browser.js'], + 'castle.browser.js': ['castle.browser.js', 'castle.umd.js'], + }; + const names = aliases[filename] || [filename]; + if (!fs.existsSync(dir)) return null; + const root = fs.realpathSync(dir); + for (const name of names) { + const candidate = path.resolve(root, name); + if (!candidate.startsWith(root + path.sep)) continue; + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + return null; +} + // True for IPv4/IPv6 loopback addresses, including the IPv4-mapped IPv6 form // Node reports for localhost connections. function isLoopback(ip) { @@ -86,6 +105,12 @@ function buildApp(castle = require('./castle')) { 'castle-js', 'dist' ); + // 2.x ships castle.browser.js; 3.x ships castle.umd.js. The HTML always requests castle.umd.js. + app.get('/vendor/castle-js/:filename', (req, res, next) => { + const file = resolveCastleJsFile(CASTLE_JS_DIR, req.params.filename); + if (!file) return next(); + res.type('application/javascript').sendFile(file); + }); app.use('/vendor/castle-js', express.static(CASTLE_JS_DIR)); // The post-login /account page is a React app (see ./react). When built, its 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/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) From 1ef1e7d6262782aa566b6c68e1d68cae8456266d Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 7 Sep 2026 18:35:50 +0200 Subject: [PATCH 2/2] Serve only the Castle UMD build from npm. --- app.js | 25 ------------------------- package.json | 3 ++- scripts/ensure-castle-umd.js | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 26 deletions(-) create mode 100644 scripts/ensure-castle-umd.js diff --git a/app.js b/app.js index 2ed7afa..59589db 100644 --- a/app.js +++ b/app.js @@ -47,25 +47,6 @@ function errorResult(err) { return { error: err instanceof APIError ? err.message : String(err) }; } -// 2.x ships castle.browser.js; 3.x ships castle.umd.js. The HTML always requests castle.umd.js. -function resolveCastleJsFile(dir, filename) { - const aliases = { - 'castle.umd.js': ['castle.umd.js', 'castle.browser.js'], - 'castle.browser.js': ['castle.browser.js', 'castle.umd.js'], - }; - const names = aliases[filename] || [filename]; - if (!fs.existsSync(dir)) return null; - const root = fs.realpathSync(dir); - for (const name of names) { - const candidate = path.resolve(root, name); - if (!candidate.startsWith(root + path.sep)) continue; - if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { - return candidate; - } - } - return null; -} - // True for IPv4/IPv6 loopback addresses, including the IPv4-mapped IPv6 form // Node reports for localhost connections. function isLoopback(ip) { @@ -105,12 +86,6 @@ function buildApp(castle = require('./castle')) { 'castle-js', 'dist' ); - // 2.x ships castle.browser.js; 3.x ships castle.umd.js. The HTML always requests castle.umd.js. - app.get('/vendor/castle-js/:filename', (req, res, next) => { - const file = resolveCastleJsFile(CASTLE_JS_DIR, req.params.filename); - if (!file) return next(); - res.type('application/javascript').sendFile(file); - }); app.use('/vendor/castle-js', express.static(CASTLE_JS_DIR)); // The post-login /account page is a React app (see ./react). When built, its 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/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); +}