Skip to content

Commit 38fdc7a

Browse files
Swap Turnstile at runtime via a noop or Cloudflare gate
Keep execute-on-Run. CloudflareTurnstileGate now follows the documented explicit lifecycle (render with execution=execute, then execute(widgetId)). Interactive challenges stay visible, have no 8s client deadline, and overlapping Run clicks share one in-flight token request. Automated browsers (navigator.webdriver) default to the noop gate so CI on 127.0.0.1 does not hang. Humans on localhost, including Vite proxying to production, still use Cloudflare. Override with window.__TRY_PLAYWRIGHT_TURNSTILE__. Co-authored-by: Max Schmitt <max@schmitt.mx>
1 parent 90009f1 commit 38fdc7a

4 files changed

Lines changed: 414 additions & 18 deletions

File tree

frontend/src/components/App/index.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
float: right
1111
}
1212

13+
.turnstile {
14+
display: inline-block;
15+
vertical-align: middle;
16+
margin-right: 10px;
17+
position: relative;
18+
z-index: 11;
19+
}
20+
1321
.codeHeaderButtons > button {
1422
margin-left: 10px
1523
}

frontend/src/components/App/index.tsx

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState, useContext, useRef } from 'react';
1+
import { useState, useContext, useEffect, useRef } from 'react';
22
import { Col, Grid, IconButton, Loader, Panel, CustomProvider } from 'rsuite'
33
import PlayIcon from '@rsuite/icons/PlayOutline';
44

55
import { ExecutionResponse, runCode, trackEvent } from '../../utils'
6+
import { createTurnstileGate, type TurnstileGate } from '../../turnstile'
67
import RightPanel from '../RightPanel'
78
import Header from '../Header'
89
import Editor from '../Editor'
@@ -17,36 +18,53 @@ const VITE_TURNSTILE_SITEKEY = '0x4AAAAAAA_K0T_2LZ0rgUtv';
1718
const App: React.FunctionComponent = () => {
1819
const { getCode, onChangeRightPanelMode, codeLanguage, onLanguageChange } = useContext(CodeContext)
1920
const [loading, setLoading] = useState<boolean>(false)
21+
const [running, setRunning] = useState<boolean>(false)
2022
const [resp, setResponse] = useState<ExecutionResponse|null>(null)
2123
const handleExecutionRef = useRef<() => Promise<void>>(undefined)
24+
const runningRef = useRef(false)
2225
const [darkMode] = useDarkMode()
2326
const turnstileRef = useRef<HTMLDivElement>(null)
27+
const gateRef = useRef<TurnstileGate | null>(null)
28+
29+
if (!gateRef.current) {
30+
gateRef.current = createTurnstileGate({ sitekey: VITE_TURNSTILE_SITEKEY })
31+
}
32+
33+
useEffect(() => {
34+
return () => {
35+
gateRef.current?.remove()
36+
}
37+
}, [])
2438

2539
const handleExecution = async (): Promise<void> => {
26-
setLoading(true)
40+
if (runningRef.current) {
41+
return
42+
}
43+
runningRef.current = true
44+
setRunning(true)
2745
setResponse(null)
2846

2947
trackEvent()
30-
const turnstileToken = await new Promise<string>((resolve) => {
31-
try {
32-
(window as any).turnstile.reset();
33-
} catch (error) {}
34-
(window as any).turnstile.execute(turnstileRef.current, {
35-
sitekey: VITE_TURNSTILE_SITEKEY,
36-
callback: (token: string) => resolve(token),
37-
'error-callback': () => resolve(''),
38-
});
39-
});
40-
// After await: do not use render-time `code` (stale vs example select).
41-
setResponse(await runCode(getCode(), codeLanguage, turnstileToken))
42-
setLoading(false)
43-
onChangeRightPanelMode(false)
48+
try {
49+
// Keep the loader off until the widget has a token so an interactive
50+
// challenge is not covered by the editor backdrop (z-index 10).
51+
const turnstileToken = await gateRef.current!.getToken(turnstileRef.current)
52+
setLoading(true)
53+
// After await: do not use render-time `code` (stale vs example select).
54+
setResponse(await runCode(getCode(), codeLanguage, turnstileToken))
55+
} catch (error) {
56+
setResponse({ error: String(error) })
57+
} finally {
58+
runningRef.current = false
59+
setRunning(false)
60+
setLoading(false)
61+
onChangeRightPanelMode(false)
62+
}
4463
}
4564
handleExecutionRef.current = handleExecution
4665

4766
return (
4867
<CustomProvider theme={darkMode ? 'dark' : 'light'}>
49-
<div ref={turnstileRef} style={{ display: 'none' }} />
5068
<Header />
5169
<Grid fluid className={styles.grid}>
5270
<Col span={{ xs: 24, md: 12 }}>
@@ -58,8 +76,9 @@ const App: React.FunctionComponent = () => {
5876
<>
5977
Editor
6078
<div className={styles.codeHeaderButtons}>
79+
<div ref={turnstileRef} className={styles.turnstile} />
6180
<CodeLanguageSelector codeLanguage={codeLanguage} onLanguageChange={onLanguageChange} />
62-
<IconButton onClick={handleExecution} icon={<PlayIcon />}>
81+
<IconButton onClick={handleExecution} icon={<PlayIcon />} disabled={running}>
6382
Run
6483
</IconButton>
6584
</div>

frontend/src/turnstile.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { test, expect } from '@playwright/experimental-ct-react'
2+
import {
3+
CloudflareTurnstileGate,
4+
NoopTurnstileGate,
5+
createTurnstileGate,
6+
resolveTurnstileMode,
7+
type TurnstileApi,
8+
type TurnstileRenderOptions,
9+
} from './turnstile'
10+
11+
const container = {} as HTMLElement
12+
13+
function mockApi(overrides: Partial<TurnstileApi> = {}): { api: TurnstileApi; options: TurnstileRenderOptions[] } {
14+
const rendered: TurnstileRenderOptions[] = []
15+
const api: TurnstileApi = {
16+
ready: (callback) => callback(),
17+
render: (_container, options) => {
18+
rendered.push(options)
19+
return 'widget-1'
20+
},
21+
execute: () => {
22+
rendered.at(-1)?.callback?.('tok')
23+
},
24+
reset: () => undefined,
25+
remove: () => undefined,
26+
...overrides,
27+
}
28+
return { api, options: rendered }
29+
}
30+
31+
test('defaults to cloudflare for humans and noop for automated browsers', () => {
32+
expect(resolveTurnstileMode({ automated: false })).toBe('cloudflare')
33+
expect(resolveTurnstileMode({ automated: true })).toBe('noop')
34+
})
35+
36+
test('window override swaps the implementation at runtime', () => {
37+
expect(resolveTurnstileMode({ automated: false, override: 'noop' })).toBe('noop')
38+
expect(resolveTurnstileMode({ automated: true, override: 'cloudflare' })).toBe('cloudflare')
39+
expect(createTurnstileGate({ automated: false, override: 'noop' })).toBeInstanceOf(NoopTurnstileGate)
40+
expect(createTurnstileGate({ automated: true, override: 'cloudflare', sitekey: 'k' })).toBeInstanceOf(CloudflareTurnstileGate)
41+
})
42+
43+
test('noop gate never calls Turnstile execute', async () => {
44+
let executed = false
45+
const gate = createTurnstileGate({
46+
mode: 'noop',
47+
getApi: () => ({
48+
render: () => 'w',
49+
execute: () => {
50+
executed = true
51+
},
52+
reset: () => undefined,
53+
}),
54+
})
55+
expect(await gate.getToken(container)).toBe('')
56+
expect(executed).toBe(false)
57+
})
58+
59+
test('cloudflare gate returns empty when the API is missing', async () => {
60+
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => null })
61+
expect(await gate.getToken(container)).toBe('')
62+
})
63+
64+
test('cloudflare gate does not hang when render throws', async () => {
65+
const gate = new CloudflareTurnstileGate('sitekey', {
66+
getApi: () => ({
67+
render: () => {
68+
throw new Error('no widget')
69+
},
70+
execute: () => undefined,
71+
reset: () => undefined,
72+
}),
73+
})
74+
expect(await gate.getToken(container)).toBe('')
75+
})
76+
77+
test('cloudflare gate renders then execute(widgetId)', async () => {
78+
const { api, options } = mockApi()
79+
let executedWith: unknown
80+
let resetWith: unknown
81+
api.execute = (target) => {
82+
executedWith = target
83+
options.at(-1)?.callback?.('tok')
84+
}
85+
api.reset = (target) => {
86+
resetWith = target
87+
}
88+
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
89+
expect(await gate.getToken(container)).toBe('tok')
90+
expect(executedWith).toBe('widget-1')
91+
expect(resetWith).toBe('widget-1')
92+
expect(options[0]).toMatchObject({
93+
sitekey: 'sitekey',
94+
execution: 'execute',
95+
appearance: 'interaction-only',
96+
})
97+
})
98+
99+
test('overlapping getToken shares one widget callback', async () => {
100+
const { api, options } = mockApi({
101+
execute: () => undefined,
102+
})
103+
let executeCount = 0
104+
api.execute = () => {
105+
executeCount += 1
106+
}
107+
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
108+
const first = gate.getToken(container)
109+
const second = gate.getToken(container)
110+
await Promise.resolve()
111+
expect(executeCount).toBe(1)
112+
options[0]?.callback?.('tok')
113+
expect(await Promise.all([first, second])).toEqual(['tok', 'tok'])
114+
})
115+
116+
test('reuses the rendered widget on a later getToken', async () => {
117+
let renderCount = 0
118+
const { api, options } = mockApi({
119+
render: (_container, renderOptions) => {
120+
renderCount += 1
121+
options.push(renderOptions)
122+
return 'widget-1'
123+
},
124+
})
125+
api.execute = () => {
126+
options.at(-1)?.callback?.(`tok-${renderCount}`)
127+
}
128+
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
129+
await gate.getToken(container)
130+
expect(await gate.getToken(container)).toBe('tok-1')
131+
expect(renderCount).toBe(1)
132+
})
133+
134+
test('cloudflare gate resolves empty on error-callback', async () => {
135+
const { api, options } = mockApi()
136+
api.execute = () => {
137+
options.at(-1)?.['error-callback']?.()
138+
}
139+
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
140+
expect(await gate.getToken(container)).toBe('')
141+
})
142+
143+
test('optional timeoutMs is for tests; production has no client deadline', async () => {
144+
const { api } = mockApi({
145+
execute: () => undefined,
146+
})
147+
const gate = new CloudflareTurnstileGate('sitekey', {
148+
timeoutMs: 50,
149+
getApi: () => api,
150+
})
151+
expect(await gate.getToken(container)).toBe('')
152+
})

0 commit comments

Comments
 (0)