Skip to content

Commit a8e6560

Browse files
committed
tunnel closes if upstream is not connected
1 parent 77d9b56 commit a8e6560

File tree

8 files changed

+534
-337
lines changed

8 files changed

+534
-337
lines changed

cloudflare-tunnel/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"repository": "https://github.com/remorses/",
77
"scripts": {
88
"deployment": "pnpm tsc && wrangler deploy --env preview",
9-
"deployment:prod": "pnpm tsc && wrangler deploy && pnpm deployment",
9+
"deployment:prod": "sudo pnpm tsc && wrangler deploy && pnpm deployment",
1010
"dev": "wrangler dev",
1111
"test": "vitest --run"
1212
},

cloudflare-tunnel/src/tunnel.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,30 @@ describe.concurrent('Tunnel WebSocket', () => {
209209
client1.close()
210210
client2.close()
211211
})
212+
213+
test('client connection fails when no upstream is connected', async () => {
214+
const tunnelId = getTunnelId()
215+
216+
// Try to connect client without upstream
217+
const client = new WebSocket(`${WS_URL}/downstream?id=${tunnelId}`)
218+
219+
// Track both open and close events
220+
const eventPromise = new Promise<{ code: number; reason: string }>((resolve, reject) => {
221+
client.on('open', () => {
222+
// Connection opened, wait for close
223+
})
224+
client.on('close', (code, reason) => {
225+
resolve({ code, reason: reason.toString() })
226+
})
227+
client.on('error', (error) => {
228+
reject(error)
229+
})
230+
})
231+
232+
// Wait for the connection to be closed
233+
const closeEvent = await eventPromise
234+
235+
expect(closeEvent.code).toBe(4008)
236+
expect(closeEvent.reason).toBe('No upstream available')
237+
})
212238
})

cloudflare-tunnel/src/tunnel.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ export class Tunnel {
8282
this.ctx.acceptWebSocket(server)
8383
server.serializeAttachment({ role: 'up' } satisfies Attachment)
8484
} else {
85+
// Check if upstream exists before accepting downstream
86+
const existingUp = this.getUpstream()
87+
if (!existingUp) {
88+
// Accept the WebSocket connection
89+
server.accept()
90+
// Immediately close it
91+
server.close(4008, 'No upstream available')
92+
// Return successful WebSocket upgrade response
93+
// The client will receive the close event after connection
94+
return addCors(new Response(null, { status: 101, webSocket: client }))
95+
}
8596
// Accept with hibernation and tag as downstream
8697
this.ctx.acceptWebSocket(server)
8798
server.serializeAttachment({ role: 'down' } satisfies Attachment)

cloudflare-tunnel/wrangler.jsonc

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,24 @@
1212
"routes": [
1313
{
1414
"pattern": "holocron.so/_tunnel/*",
15-
"zone_name": "holocron.so"
15+
"zone_name": "holocron.so",
1616
},
1717
{
1818
"pattern": "fumabase.com/_tunnel/*",
19-
"zone_name": "fumabase.com"
19+
"zone_name": "fumabase.com",
2020
},
2121
{
2222
"pattern": "unframer.co/_tunnel/*",
23-
"zone_name": "unframer.co"
24-
}
23+
"zone_name": "unframer.co",
24+
},
25+
{
26+
"pattern": "mcp.unframer.co/_tunnel/*",
27+
"zone_name": "unframer.co",
28+
},
2529
],
2630

2731
"durable_objects": {
28-
"bindings": [{ "name": "TUNNEL_DO", "class_name": "Tunnel" }]
32+
"bindings": [{ "name": "TUNNEL_DO", "class_name": "Tunnel" }],
2933
},
3034

3135
"migrations": [{ "tag": "v1", "new_classes": ["Tunnel"] }],
@@ -35,16 +39,24 @@
3539
"routes": [
3640
{
3741
"pattern": "preview.fumabase.com/_tunnel/*",
38-
"zone_name": "fumabase.com"
42+
"zone_name": "fumabase.com",
3943
},
4044
{
4145
"pattern": "preview.holocron.so/_tunnel/*",
42-
"zone_name": "holocron.so"
43-
}
46+
"zone_name": "holocron.so",
47+
},
48+
{
49+
"pattern": "mcp.preview.unframer.co/_tunnel/*",
50+
"zone_name": "unframer.co",
51+
},
52+
{
53+
"pattern": "preview.unframer.co/_tunnel/*",
54+
"zone_name": "unframer.co",
55+
},
4456
],
4557
"durable_objects": {
46-
"bindings": [{ "name": "TUNNEL_DO", "class_name": "Tunnel" }]
47-
}
48-
}
49-
}
58+
"bindings": [{ "name": "TUNNEL_DO", "class_name": "Tunnel" }],
59+
},
60+
},
61+
},
5062
}

docs-website/src/lib/docs-state.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export type ChatHistory = {
2525

2626
export type DrawerState = 'closed' | 'minimized' | 'open'
2727

28+
export type WebSocketConnectionState = 'CONNECTED' | 'UPSTREAM_NOT_CONNECTED' | 'NOT_CONNECTED'
29+
2830
export type PersistentDocsState = {
2931
chatId: string
3032
drawerState: DrawerState
@@ -34,7 +36,7 @@ export type PersistentDocsState = {
3436
export type DocsState = {
3537
// tree?: PageTree.Root
3638
toc?: TOCItemType[]
37-
websocketServerPreviewConnected?: boolean
39+
websocketConnectionState?: WebSocketConnectionState
3840
currentSlug?: string
3941
// docsJson?: DocsJsonType
4042
filesInDraft: FilesInDraft
@@ -54,6 +56,7 @@ const defaultState: DocsState = {
5456
filesInDraft: {},
5557
deletedPages: [],
5658
previewMode: 'preview',
59+
websocketConnectionState: 'NOT_CONNECTED',
5760
}
5861

5962
const defaultPersistentState: PersistentDocsState = {

docs-website/src/routes/_catchall-client.tsx

Lines changed: 103 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const openapiPath = `/api-reference`
6464

6565
const allowedOrigins = [env.NEXT_PUBLIC_URL!.replace(/\/$/, ''), 'http://localhost:7664']
6666

67-
let onFirstStateMessage = () => {}
67+
let onFirstStateMessage = () => { }
6868
const firstStateReceived = new Promise<void>((resolve) => {
6969
onFirstStateMessage = resolve
7070
})
@@ -170,43 +170,80 @@ async function websocketIdHandling(websocketId: string) {
170170
if (globalThis.websocketHandlingDone) return
171171
globalThis.websocketHandlingDone = true
172172

173-
console.log('connecting over preview websocketId', websocketId)
174-
const websocketUrl = `wss://${WEBSITE_DOMAIN}/_tunnel/client?id=${websocketId}`
175-
const ws = new WebSocket(websocketUrl)
176-
ws.onopen = () => {
177-
useDocsState.setState({
178-
websocketServerPreviewConnected: true,
179-
})
180-
ws.send(JSON.stringify({ type: 'ready' }))
181-
}
182-
ws.onclose = () => {
183-
useDocsState.setState({
184-
websocketServerPreviewConnected: false,
185-
})
173+
let ws: WebSocket | null = null
174+
let reconnectTimeout: NodeJS.Timeout | null = null
175+
let pingInterval: NodeJS.Timeout | null = null
176+
177+
const clearTimers = () => {
178+
if (reconnectTimeout) {
179+
clearTimeout(reconnectTimeout)
180+
reconnectTimeout = null
181+
}
182+
if (pingInterval) {
183+
clearInterval(pingInterval)
184+
pingInterval = null
185+
}
186186
}
187-
ws.onmessage = async (event) => {
188-
let data: IframeRpcMessage
189-
try {
190-
data = JSON.parse(event.data)
191-
} catch {
192-
console.error(`websocket sent invalid json`, event.data)
193-
return
187+
188+
const connect = () => {
189+
console.log('connecting over preview websocketId', websocketId)
190+
const websocketUrl = `wss://${WEBSITE_DOMAIN}/_tunnel/downstream?id=${websocketId}`
191+
ws = new WebSocket(websocketUrl)
192+
193+
ws.onopen = () => {
194+
clearTimers()
195+
useDocsState.setState({
196+
websocketConnectionState: 'CONNECTED',
197+
})
198+
ws!.send(JSON.stringify({ type: 'ready' }))
199+
200+
// Start ping interval
201+
pingInterval = setInterval(() => {
202+
if (ws && ws.readyState === WebSocket.OPEN) {
203+
ws.send(JSON.stringify({ type: 'ping' }))
204+
}
205+
}, 5 * 1000)
194206
}
195-
const { id, revalidate, state: partialState } = data || {}
196-
if (partialState) {
197-
await setDocsStateForMessage(partialState)
207+
208+
ws.onclose = (event) => {
209+
clearTimers()
210+
211+
// Check if upstream is not connected based on close code
212+
if (event.code === 4008) {
213+
useDocsState.setState({
214+
websocketConnectionState: 'UPSTREAM_NOT_CONNECTED',
215+
})
216+
} else {
217+
useDocsState.setState({
218+
websocketConnectionState: 'NOT_CONNECTED',
219+
})
220+
}
221+
222+
// Always attempt to reconnect after 5 seconds
223+
reconnectTimeout = setTimeout(() => connect(), 5000)
198224
}
199-
if (revalidate) {
200-
await revalidator?.revalidate()
225+
226+
ws.onmessage = async (event) => {
227+
let data: IframeRpcMessage
228+
try {
229+
data = JSON.parse(event.data)
230+
} catch {
231+
console.error(`websocket sent invalid json`, event.data)
232+
return
233+
}
234+
const { id, revalidate, state: partialState } = data || {}
235+
if (partialState) {
236+
await setDocsStateForMessage(partialState)
237+
}
238+
if (revalidate) {
239+
await revalidator?.revalidate()
240+
}
241+
ws!.send(JSON.stringify({ id } satisfies IframeRpcMessage))
201242
}
202-
ws.send(JSON.stringify({ id } satisfies IframeRpcMessage))
203243
}
204-
// ping interval
205-
setInterval(() => {
206-
if (ws.readyState === WebSocket.OPEN) {
207-
ws.send(JSON.stringify({ type: 'ping' }))
208-
}
209-
}, 1000)
244+
245+
// Start initial connection
246+
connect()
210247
}
211248

212249
if (typeof window !== 'undefined') {
@@ -293,11 +330,11 @@ export function CSSVariables({ docsJson }: { docsJson: DocsJsonType }) {
293330
const toCssBlock = (obj: Record<string, string> | undefined) =>
294331
obj
295332
? Object.entries(obj)
296-
.map(([key, value]) => {
297-
const cssVar = key.startsWith('--') ? key : `--${key}`
298-
return `${cssVar}: ${value} !important;`
299-
})
300-
.join('\n ')
333+
.map(([key, value]) => {
334+
const cssVar = key.startsWith('--') ? key : `--${key}`
335+
return `${cssVar}: ${value} !important;`
336+
})
337+
.join('\n ')
301338
: ''
302339

303340
// Don't render if both empty
@@ -552,7 +589,7 @@ function DocsLayoutWrapper({ children, docsJson }: { children: React.ReactNode;
552589
}
553590

554591
const noop = (callback: () => void) => {
555-
return () => {}
592+
return () => { }
556593
}
557594

558595
function PreviewBanner({ websocketId }: { websocketId?: string }) {
@@ -564,7 +601,7 @@ function PreviewBanner({ websocketId }: { websocketId?: string }) {
564601
globalNavigate(url.pathname + url.search)
565602
}
566603

567-
const websocketServerPreviewConnected = useDocsState((state) => state.websocketServerPreviewConnected)
604+
const websocketConnectionState = useDocsState((state) => state.websocketConnectionState)
568605

569606
const shouldShow = useSyncExternalStore(
570607
noop,
@@ -575,19 +612,41 @@ function PreviewBanner({ websocketId }: { websocketId?: string }) {
575612
return null
576613
}
577614

615+
const getStatusColor = () => {
616+
switch (websocketConnectionState) {
617+
case 'CONNECTED':
618+
return 'bg-green-500'
619+
case 'UPSTREAM_NOT_CONNECTED':
620+
return 'bg-yellow-500'
621+
case 'NOT_CONNECTED':
622+
default:
623+
return 'bg-red-500'
624+
}
625+
}
626+
627+
const getStatusMessage = () => {
628+
switch (websocketConnectionState) {
629+
case 'CONNECTED':
630+
return 'Connected to local preview. Added content will be highlighted green'
631+
case 'UPSTREAM_NOT_CONNECTED':
632+
return 'Waiting for upstream server. Please run the preview server'
633+
case 'NOT_CONNECTED':
634+
default:
635+
return 'Server disconnected. Please restart the preview server'
636+
}
637+
}
638+
578639
return (
579640
<Banner className='sticky top-0 z-50 bg-fd-muted text-fd-accent-foreground isolate px-4 py-1 flex items-center justify-between'>
580641
<div className='flex items-center gap-2'>
581642
<div
582643
className={cn(
583644
'w-2 h-2 rounded-full animate-pulse',
584-
websocketServerPreviewConnected ? 'bg-green-500' : 'bg-red-500',
645+
getStatusColor(),
585646
)}
586647
></div>
587648
<span className='font-medium text-sm'>
588-
{websocketServerPreviewConnected
589-
? 'Connected to local preview. Added content will be highlighted green'
590-
: 'Server disconnected. Please restart the preview server'}
649+
{getStatusMessage()}
591650
</span>
592651
</div>
593652
<button

fumadocs

Submodule fumadocs updated 87 files

0 commit comments

Comments
 (0)