Skip to content

Commit d79ead0

Browse files
v1rtlclaude
andcommitted
fix: use server upgrade event to access head buffer (#12)
Change API from middleware pattern to setup function to fix WebSocket connections timing out on HTTPS servers. The middleware pattern couldn't access the upgrade head buffer, causing Node.js to not properly recognize WebSocket upgrades. BREAKING CHANGE: API changed from `app.use(tinyws())` to `tinyws(app, server)` - Add paths option to restrict WebSocket handling to specific routes - Export TinyWSOptions interface for TypeScript users - Update examples and documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c4c55e6 commit d79ead0

File tree

7 files changed

+164
-41
lines changed

7 files changed

+164
-41
lines changed

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ pnpm i ws tinyws
4141
import { App, Request } from '@tinyhttp/app'
4242
import { tinyws, TinyWSRequest } from 'tinyws'
4343

44-
const app = new App<any, Request & TinyWSRequest>()
45-
46-
app.use(tinyws())
44+
const app = new App<Request & TinyWSRequest>()
4745

4846
app.use('/ws', async (req, res) => {
4947
if (req.ws) {
@@ -55,7 +53,20 @@ app.use('/ws', async (req, res) => {
5553
}
5654
})
5755

58-
app.listen(3000)
56+
const server = app.listen(3000)
57+
tinyws(app, server)
58+
```
59+
60+
### Restricting WebSocket to specific paths
61+
62+
You can restrict WebSocket handling to specific paths using the `paths` option:
63+
64+
```ts
65+
// Single path
66+
tinyws(app, server, { paths: '/ws' })
67+
68+
// Multiple paths
69+
tinyws(app, server, { paths: ['/ws', '/socket'] })
5970
```
6071

6172
See [examples](examples) for express and polka integration.

bun.lockb

2.68 KB
Binary file not shown.

examples/basic.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { type TinyWSRequest, tinyws } from '../src/index'
44

55
const app = new App<Request & TinyWSRequest>()
66

7-
app.use(tinyws())
8-
97
app.use('/hmr', async (req, res) => {
108
if (req.ws) {
119
const ws = await req.ws()
@@ -15,4 +13,5 @@ app.use('/hmr', async (req, res) => {
1513
res.send('Hello from HTTP!')
1614
})
1715

18-
app.listen(3000)
16+
const server = app.listen(3000)
17+
tinyws(app, server)

examples/express.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ declare global {
1212

1313
const app = express()
1414

15-
app.use('/hmr', tinyws(), async (req, res) => {
15+
app.use('/hmr', async (req, res) => {
1616
if (req.ws) {
1717
const ws = await req.ws()
1818

@@ -21,4 +21,5 @@ app.use('/hmr', tinyws(), async (req, res) => {
2121
res.send('Hello from HTTP!')
2222
})
2323

24-
app.listen(3000)
24+
const server = app.listen(3000)
25+
tinyws({ handler: app }, server)

examples/polka.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { type TinyWSRequest, tinyws } from '../src/index'
44

55
const app = polka<polka.Request & TinyWSRequest>()
66

7-
app.use(tinyws())
8-
97
app.use('/hmr', async (req, res) => {
108
if (req.ws) {
119
const ws = await req.ws()
@@ -15,4 +13,5 @@ app.use('/hmr', async (req, res) => {
1513
res.end('Hello from HTTP!')
1614
})
1715

18-
app.listen(3000)
16+
const server = app.listen(3000)
17+
tinyws({ handler: app.handler }, server)

src/index.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
1-
import type * as http from 'node:http'
1+
import * as http from 'node:http'
2+
import type { Socket } from 'node:net'
23
import type { ServerOptions, WebSocket } from 'ws'
3-
import { WebSocketServer as Server } from 'ws'
4+
import { WebSocketServer } from 'ws'
45

56
export interface TinyWSRequest extends http.IncomingMessage {
67
ws: () => Promise<WebSocket>
78
}
89

10+
export interface TinyWSOptions extends ServerOptions {
11+
paths?: string | string[]
12+
}
13+
914
/**
1015
* tinyws - adds `req.ws` method that resolves when websocket request appears
11-
* @param wsOptions
16+
* @param app - The application instance with a handler function
17+
* @param server - The HTTP server instance
18+
* @param options - Optional WebSocket server options and paths to restrict WebSocket handling
19+
* @param wss - Optional existing WebSocketServer instance
20+
* @returns The WebSocketServer instance
1221
*/
13-
export const tinyws =
14-
(wsOptions?: ServerOptions, wss: Server = new Server({ ...wsOptions, noServer: true })) =>
15-
async (req: TinyWSRequest, _: unknown, next: () => void | Promise<void>) => {
16-
const upgradeHeader = (req.headers.upgrade || '').split(',').map((s) => s.trim())
17-
18-
// When upgrade header contains "websocket" it's index is 0
19-
if (upgradeHeader.indexOf('websocket') === 0) {
20-
req.ws = () =>
22+
export const tinyws = (
23+
app: { handler: (req: any, res: any) => void },
24+
server: http.Server,
25+
options?: TinyWSOptions,
26+
wss: WebSocketServer = new WebSocketServer({ ...options, noServer: true })
27+
) => {
28+
const { paths, ...wsOptions } = options || {}
29+
const allowedPaths = paths ? (Array.isArray(paths) ? paths : [paths]) : null
30+
31+
const upgradeHandler = (request: http.IncomingMessage, socket: Socket, head: Buffer) => {
32+
const response = new http.ServerResponse(request)
33+
response.assignSocket(socket)
34+
35+
// Copy the head buffer to avoid keeping the entire slab buffer alive
36+
const copyOfHead = Buffer.alloc(head.length)
37+
head.copy(copyOfHead)
38+
39+
response.on('finish', () => {
40+
if (response.socket !== null) {
41+
response.socket.destroy()
42+
}
43+
})
44+
45+
const upgradeHeader = (request.headers.upgrade || '').split(',').map((s) => s.trim())
46+
const requestPath = request.url?.split('?')[0] || '/'
47+
48+
const pathMatches = allowedPaths === null || allowedPaths.some((p) => requestPath.startsWith(p))
49+
50+
if (upgradeHeader.indexOf('websocket') === 0 && pathMatches) {
51+
;(request as TinyWSRequest).ws = () =>
2152
new Promise((resolve) => {
22-
wss.handleUpgrade(req, req.socket, Buffer.alloc(0), (ws) => {
23-
wss.emit('connection', ws, req)
53+
wss.handleUpgrade(request, socket, copyOfHead, (ws) => {
54+
wss.emit('connection', ws, request)
2455
resolve(ws)
2556
})
2657
})
2758
}
2859

29-
await next()
60+
app.handler(request, response)
3061
}
62+
63+
server.on('upgrade', upgradeHandler)
64+
return wss
65+
}

tests/index.test.ts

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,30 @@ import { it } from 'bun:test'
22
import * as assert from 'node:assert'
33
import { once } from 'node:events'
44
import { App, type Request } from '@tinyhttp/app'
5-
import { type Server, type ServerOptions, WebSocketServer } from 'ws'
5+
import { type Server, type ServerOptions, WebSocket, WebSocketServer } from 'ws'
66
import { type TinyWSRequest, tinyws } from '../src/index'
77

88
const s = (handler: (req: TinyWSRequest) => void, opts?: ServerOptions, inst?: Server) => {
99
const app = new App<Request & TinyWSRequest>()
1010

11-
app.use(tinyws(opts, inst))
1211
app.use('/ws', async (req) => {
1312
if (typeof req.ws !== 'undefined') {
1413
handler(req)
1514
}
1615
})
1716

18-
return app
17+
return { app, opts, inst }
1918
}
2019

2120
it('should respond with a message', async () => {
22-
const app = s(async (req) => {
21+
const { app } = s(async (req) => {
2322
const ws = await req?.ws()
2423

2524
return ws.send('hello there')
2625
})
2726

2827
const server = app.listen(4443, undefined, 'localhost')
28+
tinyws(app, server)
2929

3030
const ws = new WebSocket('ws://localhost:4443/ws')
3131

@@ -37,15 +37,16 @@ it('should respond with a message', async () => {
3737
})
3838

3939
it('should resolve a `.ws` property', async () => {
40-
const app = s(async (req) => {
40+
const { app } = s(async (req) => {
4141
const ws = await req.ws()
4242

43-
assert.ok(ws instanceof WebSocket)
43+
assert.ok(typeof ws.send === 'function')
4444

4545
return ws.send('hello there')
4646
})
4747

4848
const server = app.listen(4444, undefined, 'localhost')
49+
tinyws(app, server)
4950

5051
const ws = new WebSocket('ws://localhost:4444/ws')
5152

@@ -56,11 +57,11 @@ it('should resolve a `.ws` property', async () => {
5657
})
5758

5859
it('should pass ws options', async () => {
59-
const app = s(
60+
const { app, opts } = s(
6061
async (req) => {
6162
const ws = await req.ws()
6263

63-
assert.ok(ws instanceof WebSocket)
64+
assert.ok(typeof ws.send === 'function')
6465

6566
ws.on('error', (err) => {
6667
assert.match(err.message, /Max payload size exceeded/)
@@ -74,6 +75,7 @@ it('should pass ws options', async () => {
7475
)
7576

7677
const server = app.listen(4445, undefined, 'localhost')
78+
tinyws(app, server, opts)
7779

7880
const ws = new WebSocket('ws://localhost:4445/ws')
7981

@@ -86,15 +88,16 @@ it('should pass ws options', async () => {
8688
})
8789

8890
it('should accept messages', async () => {
89-
const app = s(async (req) => {
91+
const { app } = s(async (req) => {
9092
const ws = await req.ws()
9193

92-
assert.ok(ws instanceof WebSocket)
94+
assert.ok(typeof ws.send === 'function')
9395

9496
return ws.on('message', (msg) => ws.send(`You sent: ${msg}`))
9597
})
9698

9799
const server = app.listen(4446, undefined, 'localhost')
100+
tinyws(app, server)
98101

99102
const ws = new WebSocket('ws://localhost:4446/ws')
100103

@@ -114,14 +117,14 @@ it('supports passing a server instance', async () => {
114117
const wss = new WebSocketServer({ noServer: true })
115118

116119
wss.on('connection', (socket) => {
117-
assert.ok(socket instanceof WebSocket)
120+
assert.ok(typeof socket.send === 'function')
118121
})
119122

120-
const app = s(
123+
const { app, inst } = s(
121124
async (req) => {
122125
const ws = await req.ws()
123126

124-
assert.ok(ws instanceof WebSocket)
127+
assert.ok(typeof ws.send === 'function')
125128

126129
return ws.send('hello there')
127130
},
@@ -130,6 +133,7 @@ it('supports passing a server instance', async () => {
130133
)
131134

132135
const server = app.listen(4447, undefined, 'localhost')
136+
tinyws(app, server, {}, inst)
133137

134138
const ws = new WebSocket('ws://localhost:4447/ws')
135139

@@ -139,6 +143,80 @@ it('supports passing a server instance', async () => {
139143
ws.close()
140144
})
141145

142-
it('returns a middleware function', () => {
143-
assert.ok(typeof tinyws() === 'function')
146+
it('returns a WebSocketServer instance', () => {
147+
const app = new App()
148+
const server = app.listen(4448, undefined, 'localhost')
149+
const wss = tinyws(app, server)
150+
assert.ok(wss instanceof WebSocketServer)
151+
server.close()
152+
})
153+
154+
it('restricts WebSocket to specified paths', async () => {
155+
const app = new App<Request & TinyWSRequest>()
156+
157+
app.use('/ws', async (req, res) => {
158+
if (req.ws) {
159+
const ws = await req.ws()
160+
return ws.send('allowed')
161+
}
162+
res.send('no ws')
163+
})
164+
165+
app.use('/other', async (req, res) => {
166+
if (req.ws) {
167+
const ws = await req.ws()
168+
return ws.send('should not happen')
169+
}
170+
res.send('no ws on other')
171+
})
172+
173+
const server = app.listen(4449, undefined, 'localhost')
174+
tinyws(app, server, { paths: '/ws' })
175+
176+
// Connection to /ws should work
177+
const ws1 = new WebSocket('ws://localhost:4449/ws')
178+
const [data] = await once(ws1, 'message')
179+
assert.equal(data.toString(), 'allowed')
180+
ws1.close()
181+
182+
// Connection to /other should not have req.ws
183+
const ws2 = new WebSocket('ws://localhost:4449/other')
184+
const [err] = await once(ws2, 'error')
185+
assert.ok(err)
186+
ws2.close()
187+
188+
server.close()
189+
})
190+
191+
it('supports multiple paths', async () => {
192+
const app = new App<Request & TinyWSRequest>()
193+
194+
app.use('/ws1', async (req) => {
195+
if (req.ws) {
196+
const ws = await req.ws()
197+
return ws.send('ws1')
198+
}
199+
})
200+
201+
app.use('/ws2', async (req) => {
202+
if (req.ws) {
203+
const ws = await req.ws()
204+
return ws.send('ws2')
205+
}
206+
})
207+
208+
const server = app.listen(4450, undefined, 'localhost')
209+
tinyws(app, server, { paths: ['/ws1', '/ws2'] })
210+
211+
const ws1 = new WebSocket('ws://localhost:4450/ws1')
212+
const [data1] = await once(ws1, 'message')
213+
assert.equal(data1.toString(), 'ws1')
214+
ws1.close()
215+
216+
const ws2 = new WebSocket('ws://localhost:4450/ws2')
217+
const [data2] = await once(ws2, 'message')
218+
assert.equal(data2.toString(), 'ws2')
219+
ws2.close()
220+
221+
server.close()
144222
})

0 commit comments

Comments
 (0)