Skip to content

Commit 7f9f8c6

Browse files
hi-ogawasapphi-red
andauthored
fix: use websocket to test server liveness before client reload (#17891)
Co-authored-by: sapphi-red <[email protected]>
1 parent 91a1acb commit 7f9f8c6

File tree

13 files changed

+170
-22
lines changed

13 files changed

+170
-22
lines changed

packages/vite/src/client/client.ts

+22-18
Original file line numberDiff line numberDiff line change
@@ -331,24 +331,28 @@ async function waitForSuccessfulPing(
331331
hostAndPath: string,
332332
ms = 1000,
333333
) {
334-
const pingHostProtocol = socketProtocol === 'wss' ? 'https' : 'http'
335-
336-
const ping = async () => {
337-
// A fetch on a websocket URL will return a successful promise with status 400,
338-
// but will reject a networking error.
339-
// When running on middleware mode, it returns status 426, and a cors error happens if mode is not no-cors
340-
try {
341-
await fetch(`${pingHostProtocol}://${hostAndPath}`, {
342-
mode: 'no-cors',
343-
headers: {
344-
// Custom headers won't be included in a request with no-cors so (ab)use one of the
345-
// safelisted headers to identify the ping request
346-
Accept: 'text/x-vite-ping',
347-
},
348-
})
349-
return true
350-
} catch {}
351-
return false
334+
async function ping() {
335+
const socket = new WebSocket(
336+
`${socketProtocol}://${hostAndPath}`,
337+
'vite-ping',
338+
)
339+
return new Promise<boolean>((resolve) => {
340+
function onOpen() {
341+
resolve(true)
342+
close()
343+
}
344+
function onError() {
345+
resolve(false)
346+
close()
347+
}
348+
function close() {
349+
socket.removeEventListener('open', onOpen)
350+
socket.removeEventListener('error', onError)
351+
socket.close()
352+
}
353+
socket.addEventListener('open', onOpen)
354+
socket.addEventListener('error', onError)
355+
})
352356
}
353357

354358
if (await ping()) {

packages/vite/src/node/server/ws.ts

+35-4
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ export function createWebSocketServer(
133133
wss = new WebSocketServerRaw({ noServer: true })
134134
hmrServerWsListener = (req, socket, head) => {
135135
if (
136-
req.headers['sec-websocket-protocol'] === HMR_HEADER &&
136+
[HMR_HEADER, 'vite-ping'].includes(
137+
req.headers['sec-websocket-protocol']!,
138+
) &&
137139
req.url === hmrBase
138140
) {
139141
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
@@ -157,17 +159,46 @@ export function createWebSocketServer(
157159
})
158160
res.end(body)
159161
}) as Parameters<typeof createHttpServer>[1]
162+
// vite dev server in middleware mode
163+
// need to call ws listen manually
160164
if (httpsOptions) {
161165
wsHttpServer = createHttpsServer(httpsOptions, route)
162166
} else {
163167
wsHttpServer = createHttpServer(route)
164168
}
165-
// vite dev server in middleware mode
166-
// need to call ws listen manually
167-
wss = new WebSocketServerRaw({ server: wsHttpServer })
169+
wss = new WebSocketServerRaw({ noServer: true })
170+
wsHttpServer.on('upgrade', (req, socket, head) => {
171+
const protocol = req.headers['sec-websocket-protocol']!
172+
if (protocol === 'vite-ping' && server && !server.listening) {
173+
// reject connection to tell the vite/client that the server is not ready
174+
// if the http server is not listening
175+
// because the ws server listens before the http server listens
176+
req.destroy()
177+
return
178+
}
179+
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
180+
wss.emit('connection', ws, req)
181+
})
182+
})
183+
wsHttpServer.on('error', (e: Error & { code: string }) => {
184+
if (e.code === 'EADDRINUSE') {
185+
config.logger.error(
186+
colors.red(`WebSocket server error: Port is already in use`),
187+
{ error: e },
188+
)
189+
} else {
190+
config.logger.error(
191+
colors.red(`WebSocket server error:\n${e.stack || e.message}`),
192+
{ error: e },
193+
)
194+
}
195+
})
168196
}
169197

170198
wss.on('connection', (socket) => {
199+
if (socket.protocol === 'vite-ping') {
200+
return
201+
}
171202
socket.on('message', (raw) => {
172203
if (!customListeners.size) return
173204
let parsed: any
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import path from 'node:path'
2+
import { type ServerOptions, type ViteDevServer, createServer } from 'vite'
3+
import { afterEach, describe, expect, test } from 'vitest'
4+
import { hmrPorts, isServe, page, ports } from '~utils'
5+
6+
let server: ViteDevServer
7+
8+
afterEach(async () => {
9+
await server?.close()
10+
})
11+
12+
async function testClientReload(serverOptions: ServerOptions) {
13+
// start server
14+
server = await createServer({
15+
root: path.resolve(import.meta.dirname, '..'),
16+
logLevel: 'silent',
17+
server: {
18+
strictPort: true,
19+
...serverOptions,
20+
},
21+
})
22+
23+
await server.listen()
24+
const serverUrl = server.resolvedUrls.local[0]
25+
26+
// open page and wait for connection
27+
const connectedPromise = page.waitForEvent('console', {
28+
predicate: (message) => message.text().includes('[vite] connected.'),
29+
timeout: 5000,
30+
})
31+
await page.goto(serverUrl)
32+
await connectedPromise
33+
34+
// input state
35+
await page.locator('input').fill('hello')
36+
37+
// restart and wait for reconnection after reload
38+
const reConnectedPromise = page.waitForEvent('console', {
39+
predicate: (message) => message.text().includes('[vite] connected.'),
40+
timeout: 5000,
41+
})
42+
await server.restart()
43+
await reConnectedPromise
44+
expect(await page.textContent('input')).toBe('')
45+
}
46+
47+
describe.runIf(isServe)('client-reload', () => {
48+
test('default', async () => {
49+
await testClientReload({
50+
port: ports['client-reload'],
51+
})
52+
})
53+
54+
test('custom hmr port', async () => {
55+
await testClientReload({
56+
port: ports['client-reload/hmr-port'],
57+
hmr: {
58+
port: hmrPorts['client-reload/hmr-port'],
59+
},
60+
})
61+
})
62+
63+
test('custom hmr port and cross origin isolation', async () => {
64+
await testClientReload({
65+
port: ports['client-reload/cross-origin'],
66+
hmr: {
67+
port: hmrPorts['client-reload/cross-origin'],
68+
},
69+
headers: {
70+
'Cross-Origin-Embedder-Policy': 'require-corp',
71+
'Cross-Origin-Opener-Policy': 'same-origin',
72+
},
73+
})
74+
})
75+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// do nothing here since server is managed inside spec
2+
export async function serve(): Promise<{ close(): Promise<void> }> {
3+
return {
4+
close: () => Promise.resolve(),
5+
}
6+
}

playground/client-reload/index.html

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<body>
2+
<h4>Test Client Reload</h4>
3+
<input />
4+
</body>

playground/client-reload/package.json

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@vitejs/test-client-reload",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"debug": "node --inspect-brk ../../packages/vite/bin/vite",
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
}
12+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineConfig } from 'vite'
2+
3+
export default defineConfig({
4+
server: {},
5+
})

playground/js-sourcemap/test-ssr-dev.js

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ async function runTest() {
1212
server: {
1313
middlewareMode: true,
1414
hmr: false,
15+
ws: false,
1516
},
1617
define: {
1718
__testDefineObject: '{ "hello": "test" }',

playground/ssr-html/test-network-imports.js

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ async function runTest(userRunner) {
88
root: fileURLToPath(new URL('.', import.meta.url)),
99
server: {
1010
middlewareMode: true,
11+
ws: false,
1112
},
1213
})
1314
let mod

playground/ssr-html/test-stacktrace-runtime.js

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const server = await createServer({
1010
root: fileURLToPath(new URL('.', import.meta.url)),
1111
server: {
1212
middlewareMode: true,
13+
ws: false,
1314
},
1415
})
1516

playground/ssr-html/test-stacktrace.js

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const vite = await createServer({
2929
logLevel: isTest ? 'error' : 'info',
3030
server: {
3131
middlewareMode: true,
32+
ws: false,
3233
},
3334
appType: 'custom',
3435
})

playground/test-utils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export const ports = {
4747
'css/dynamic-import': 5007,
4848
'css/lightningcss-proxy': 5008,
4949
'backend-integration': 5009,
50+
'client-reload': 5010,
51+
'client-reload/hmr-port': 5011,
52+
'client-reload/cross-origin': 5012,
5053
}
5154
export const hmrPorts = {
5255
'optimize-missing-deps': 24680,
@@ -58,6 +61,8 @@ export const hmrPorts = {
5861
'css/lightningcss-proxy': 24686,
5962
json: 24687,
6063
'ssr-conditions': 24688,
64+
'client-reload/hmr-port': 24689,
65+
'client-reload/cross-origin': 24690,
6166
}
6267

6368
const hexToNameMap: Record<string, string> = {}

pnpm-lock.yaml

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)