Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions server/httpServerApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const { createServer } = require('http')

/**
* Opt-in Node http.Server API for Express apps (Socket.IO / ws on the same port).
* @param {import('express').Express} server
*/
function enableHttpServerApi(server) {
let useHttp = false
let httpServer = null

Object.defineProperty(server, 'useHttp', {
configurable: true,
enumerable: true,
get() {
return useHttp
},
set(value) {
useHttp = !!value
if (useHttp && !httpServer) {
httpServer = createServer(server)
}
},
})

Object.defineProperty(server, 'http', {
configurable: true,
enumerable: true,
get() {
if (!httpServer) {
throw new Error(
'Nullstack: set server.useHttp = true before accessing server.http (required for WebSockets/Socket.IO)',
)
}
return httpServer
},
})

return server
}

/**
* Target used by Nullstack auto-listen.
* @param {import('express').Express} server
*/
function getListenTarget(server) {
return server.useHttp ? server.http : server
}

module.exports = { enableHttpServerApi, getListenTarget }
7 changes: 5 additions & 2 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import environment from './environment'
import exposeServerFunctions from './exposeServerFunctions'
import { generateFile } from './files'
import hmr from './hmr'
import { enableHttpServerApi, getListenTarget } from './httpServerApi'
import generateManifest from './manifest'
import { prerender } from './prerender'
import printError from './printError'
Expand All @@ -20,6 +21,7 @@ import { generateServiceWorker } from './worker'
import { load } from './lazy'

const server = express()
enableHttpServerApi(server)

server.port = process.env.NULLSTACK_SERVER_PORT || process.env.PORT || 3000

Expand Down Expand Up @@ -222,14 +224,15 @@ server.start = function () {
}

if (!server.less) {
server.listen(server.port, async () => {
const onListen = async () => {
if (environment.production) {
console.info(
'\x1b[36m%s\x1b[0m',
` ✅️ Your application is ready at http://${process.env.NULLSTACK_PROJECT_DOMAIN}:${process.env.NULLSTACK_SERVER_PORT}\n`,
)
}
})
}
getListenTarget(server).listen(server.port, onListen)
}
}

Expand Down
128 changes: 128 additions & 0 deletions tests/src/HttpServerApi.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @jest-environment node
*/
const express = require('express')
const http = require('http')
const { enableHttpServerApi, getListenTarget } = require('../../server/httpServerApi')

function createApp() {
const app = express()
enableHttpServerApi(app)
return app
}

describe('server.useHttp / server.http', () => {
test('useHttp defaults to false', () => {
const app = createApp()
expect(app.useHttp).toBe(false)
})

test('accessing http without useHttp throws', () => {
const app = createApp()
expect(() => app.http).toThrow(/set server\.useHttp = true/)
})

test('useHttp = true creates a Node http.Server wrapping Express', () => {
const app = createApp()
app.useHttp = true
expect(app.useHttp).toBe(true)
expect(app.http).toBeInstanceOf(http.Server)
})

test('useHttp coerces truthy values', () => {
const app = createApp()
app.useHttp = 1
expect(app.useHttp).toBe(true)
expect(app.http).toBeInstanceOf(http.Server)
})

test('http instance is stable across accesses', () => {
const app = createApp()
app.useHttp = true
expect(app.http).toBe(app.http)
})

test('setting useHttp true again keeps the same http server', () => {
const app = createApp()
app.useHttp = true
const first = app.http
app.useHttp = true
expect(app.http).toBe(first)
})

test('getListenTarget returns Express when useHttp is false', () => {
const app = createApp()
expect(getListenTarget(app)).toBe(app)
})

test('getListenTarget returns http.Server when useHttp is true', () => {
const app = createApp()
app.useHttp = true
expect(getListenTarget(app)).toBe(app.http)
})

test('http.Server serves Express routes on the same port', (done) => {
const app = createApp()
app.useHttp = true
app.get('/ping', (_request, response) => {
response.send('pong')
})

app.http.listen(0, async () => {
try {
const { port } = app.http.address()
const response = await fetch(`http://127.0.0.1:${port}/ping`)
const body = await response.text()
expect(response.status).toBe(200)
expect(body).toBe('pong')
app.http.close(done)
} catch (error) {
app.http.close(() => done(error))
}
})
})

test('upgrade listeners can attach to server.http', (done) => {
const app = createApp()
app.useHttp = true

let upgraded = false
app.http.on('upgrade', (request, socket) => {
upgraded = request.url === '/socket-test'
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'\r\n',
)
socket.end()
})

app.http.listen(0, () => {
const { port } = app.http.address()
const client = http.request({
host: '127.0.0.1',
port,
path: '/socket-test',
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version': '13',
},
})

client.on('upgrade', (_response, socket) => {
expect(upgraded).toBe(true)
socket.destroy()
app.http.close(done)
})

client.on('error', (error) => {
app.http.close(() => done(error))
})

client.end()
})
})
})
29 changes: 29 additions & 0 deletions types/Server.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Server as HttpServer } from 'http'

export interface NullstackServer {
get(...args)

Expand All @@ -18,4 +20,31 @@ export interface NullstackServer {
port: number

maximumPayloadSize: string

/**
* Opt-in: create a Node `http.Server` wrapping the Express app.
* Enable only when you need WebSockets/Socket.IO on the same port.
* Must be set to `true` before accessing `server.http`.
*
* @example
* ```
* const context = Nullstack.start(Application)
* context.server.useHttp = true
* const io = new Server(context.server.http)
* ```
*/
useHttp?: boolean

/**
* Node HTTP server wrapping the Express app.
* Available only after `server.useHttp = true`.
* Attach Socket.IO, `ws`, or other upgrade handlers here before listen.
*/
readonly http: HttpServer

/**
* When true, skip auto-listen (used when the server bundle is required by SPA/SSG builders).
* Not needed to attach WebSockets — use `server.useHttp` + `server.http` instead.
*/
less?: boolean
}
Loading