Skip to content

Commit f944e25

Browse files
authored
feat(dev): Wait for API server to start before sending gql requests (#1015)
1 parent b005dad commit f944e25

File tree

4 files changed

+175
-57
lines changed

4 files changed

+175
-57
lines changed

packages/router/src/splash-page.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export const SplashPage = ({
1313
hasGeneratedRoutes,
1414
allStandardRoutes: routesMap,
1515
}: SplashPageProps) => {
16+
const version = useVersion()
17+
1618
const routes = Object.values(routesMap)
1719

18-
const version = useVersion()
1920
return (
2021
<>
2122
<main>
@@ -512,7 +513,8 @@ export const SplashPage = ({
512513
}
513514

514515
const useVersion = () => {
515-
const [version, setVersion] = useState(null)
516+
const [version, setVersion] = useState<string | undefined>(undefined)
517+
516518
useEffect(() => {
517519
async function fetchVersion() {
518520
try {
@@ -530,9 +532,14 @@ const useVersion = () => {
530532
)
531533

532534
const versionData = await response.json()
533-
setVersion(versionData?.data?.redwood?.version || null)
535+
536+
if (versionData.errors) {
537+
console.error('Unable to get Cedar version:', versionData.errors)
538+
}
539+
540+
setVersion(versionData?.data?.redwood?.version)
534541
} catch (err) {
535-
console.error('Unable to get CedarJS version: ', err)
542+
console.error('Unable to get Cedar version: ', err)
536543
}
537544
}
538545

@@ -542,5 +549,6 @@ const useVersion = () => {
542549

543550
fetchVersion()
544551
}, [])
552+
545553
return version
546554
}

packages/vite/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { cedarEntryInjectionPlugin } from './plugins/vite-plugin-cedar-entry-inj
1414
import { cedarHtmlEnvPlugin } from './plugins/vite-plugin-cedar-html-env.js'
1515
import { cedarNodePolyfills } from './plugins/vite-plugin-cedar-node-polyfills.js'
1616
import { cedarRemoveFromBundle } from './plugins/vite-plugin-cedar-remove-from-bundle.js'
17+
import { cedarWaitForApiServer } from './plugins/vite-plugin-cedar-wait-for-api-server.js'
1718
import { cedarTransformJsAsJsx } from './plugins/vite-plugin-jsx-loader.js'
1819
import { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js'
1920
import { cedarSwapApolloProvider } from './plugins/vite-plugin-swap-apollo-provider.js'
@@ -63,6 +64,7 @@ export function cedar({ mode }: PluginOptions = {}): PluginOption[] {
6364
mode === 'test' && cedarJsRouterImportTransformPlugin(),
6465
mode === 'test' && createAuthImportTransformPlugin(),
6566
mode === 'test' && autoImportsPlugin(),
67+
cedarWaitForApiServer(),
6668
cedarNodePolyfills(),
6769
cedarHtmlEnvPlugin(),
6870
cedarEntryInjectionPlugin(),

packages/vite/src/lib/getMergedConfig.ts

Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -70,59 +70,9 @@ export function getMergedConfig(cedarConfig: Config, cedarPaths: Paths) {
7070
[cedarConfig.web.apiUrl]: {
7171
target: `http://${apiHost}:${apiPort}`,
7272
changeOrigin: false,
73-
// Remove the `.redwood/functions` part, but leave the `/graphql`
74-
rewrite: (path) => path.replace(cedarConfig.web.apiUrl, ''),
75-
configure: (proxy) => {
76-
// @MARK: this is a hack to prevent showing confusing proxy
77-
// errors on startup because Vite launches so much faster than
78-
// the API server.
79-
let waitingForApiServer = true
80-
81-
// Wait for 2.5s, then restore regular proxy error logging
82-
setTimeout(() => {
83-
waitingForApiServer = false
84-
}, 2500)
85-
86-
proxy.on('error', (err, req, res) => {
87-
const isWaiting =
88-
waitingForApiServer && err.message.includes('ECONNREFUSED')
89-
90-
if (!isWaiting) {
91-
console.error(err)
92-
}
93-
94-
// This heuristic isn't perfect. It's written to handle dbAuth.
95-
// But it's very unlikely the user would have code that does
96-
// this exact request without it being an auth token request.
97-
// We need this special handling because we don't want the error
98-
// message below to be used as the auth token.
99-
const isAuthTokenRequest =
100-
isWaiting && req.url === '/auth?method=getToken'
101-
102-
const waitingMessage =
103-
'⌛ API Server launching, please refresh your page...'
104-
const genericMessage =
105-
'The Cedar API server is not available or is currently ' +
106-
'reloading. Please refresh.'
107-
108-
const responseBody = {
109-
errors: [
110-
{ message: isWaiting ? waitingMessage : genericMessage },
111-
],
112-
}
113-
114-
// Use 203 to indicate that the response was modified by a proxy
115-
res.writeHead(203, {
116-
'Content-Type': 'application/json',
117-
'Cache-Control': 'no-cache',
118-
})
119-
120-
if (!isAuthTokenRequest) {
121-
res.write(JSON.stringify(responseBody))
122-
}
123-
124-
res.end()
125-
})
73+
rewrite: (path) => {
74+
// Remove the `.redwood/functions` part, but leave the `/graphql`
75+
return path.replace(cedarConfig.web.apiUrl, '')
12676
},
12777
},
12878
},
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import net from 'node:net'
2+
3+
import type { PluginOption, ViteDevServer } from 'vite'
4+
5+
import { getConfig } from '@cedarjs/project-config'
6+
7+
let waitingPromise: Promise<void> | null = null
8+
let serverHasBeenUp = false
9+
10+
export function cedarWaitForApiServer(): PluginOption {
11+
const cedarConfig = getConfig()
12+
const apiPort = cedarConfig.api.port
13+
const apiHost = cedarConfig.api.host || 'localhost'
14+
15+
return {
16+
name: 'cedar-wait-for-api-server',
17+
apply: 'serve',
18+
configureServer(server: ViteDevServer) {
19+
server.middlewares.use(async (req, res, next) => {
20+
const url = req.originalUrl
21+
22+
const apiUrl = cedarConfig.web.apiUrl.replace(/\/$/, '')
23+
// By default the GraphQL API URL is apiUrl + '/graphql'. It is
24+
// however possible to configure it to something completely different,
25+
// so we have to check it separately
26+
const apiGqlUrl = cedarConfig.web.apiGraphQLUrl ?? apiUrl + '/graphql'
27+
28+
const isApiRequest =
29+
url &&
30+
(url.startsWith(apiUrl) ||
31+
// Only match on .../graphql not on .../graphql-foo. That's why I
32+
// don't use startsWith here
33+
url === apiGqlUrl ||
34+
// The two checks below are for when we support GraphQL-over-HTTP
35+
url.startsWith(apiGqlUrl + '/') ||
36+
url.startsWith(apiGqlUrl + '?'))
37+
38+
if (!isApiRequest || serverHasBeenUp) {
39+
return next()
40+
}
41+
42+
try {
43+
// Reuse existing promise if already waiting
44+
if (!waitingPromise) {
45+
waitingPromise = waitForPort(apiPort, apiHost).finally(() => {
46+
// Clear once resolved (success or failure) so future requests
47+
// after a timeout can retry
48+
waitingPromise = null
49+
})
50+
}
51+
52+
await waitingPromise
53+
54+
// Once we've confirmed that the server is listening for requests we
55+
// don't want to wait again. This ensures we fail fast and let Vite's
56+
// regular error handling take over if the server crashes mid-session
57+
serverHasBeenUp = true
58+
} catch {
59+
const message =
60+
'Vite timed out waiting for the Cedar API server ' +
61+
`at ${apiHost}:${apiPort}` +
62+
'\n' +
63+
'Please manually refresh the page when the server is ready'
64+
65+
// The `console.error` call here makes the error show in the terminal.
66+
// The response we send further down makes the error show in the
67+
// browser.
68+
console.error(message)
69+
70+
// This heuristic isn't perfect. It's written to handle dbAuth.
71+
// But it's very unlikely the user would have code that does
72+
// this exact request without it being a auth token request.
73+
// We need this special handling because we don't want the error
74+
// message below to be used as the auth token.
75+
const isAuthTokenRequest = url === apiUrl + '/auth?method=getToken'
76+
77+
const responseBody = {
78+
errors: [{ message }],
79+
}
80+
81+
// drain any incoming request body so the socket isn't left with
82+
// unread bytes
83+
req.resume()
84+
85+
const body = JSON.stringify(responseBody)
86+
87+
// Use 203 to indicate that the response was modified by a proxy
88+
res.writeHead(203, {
89+
'Content-Type': 'application/json',
90+
'Cache-Control': 'no-cache',
91+
...(!isAuthTokenRequest && {
92+
'Content-Length': Buffer.byteLength(body),
93+
}),
94+
Connection: 'close',
95+
})
96+
97+
return isAuthTokenRequest ? res.end() : res.end(body)
98+
}
99+
100+
next()
101+
})
102+
},
103+
}
104+
}
105+
106+
const ONE_MINUTE_IN_MS = 60000
107+
108+
async function waitForPort(port: number, host: string) {
109+
const start = Date.now()
110+
let lastLogTime = Date.now()
111+
while (Date.now() - start < ONE_MINUTE_IN_MS) {
112+
const isOpen = await checkPort(port, host)
113+
114+
if (isOpen) {
115+
if (lastLogTime - start >= 6000) {
116+
console.log('✅ API server is ready')
117+
}
118+
119+
return
120+
}
121+
122+
// Only log every 6 seconds, i.e. 10 times per minute
123+
const now = Date.now()
124+
if (now - lastLogTime >= 6000) {
125+
console.log('⏳ Waiting for API server...')
126+
lastLogTime = now
127+
}
128+
129+
await new Promise((resolve) => setTimeout(resolve, 500))
130+
}
131+
132+
throw new Error('Timeout waiting for port')
133+
}
134+
135+
function checkPort(port: number, host: string) {
136+
return new Promise((resolve) => {
137+
const socket = new net.Socket()
138+
139+
socket.setTimeout(200)
140+
141+
socket.on('connect', () => {
142+
socket.destroy()
143+
resolve(true)
144+
})
145+
146+
socket.on('timeout', () => {
147+
socket.destroy()
148+
resolve(false)
149+
})
150+
151+
socket.on('error', () => {
152+
socket.destroy()
153+
resolve(false)
154+
})
155+
156+
socket.connect(port, host)
157+
})
158+
}

0 commit comments

Comments
 (0)