diff --git a/src/index.ts b/src/index.ts index 425ea8c..56e31ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import assert from 'node:assert' import { once } from 'node:events' import http from 'node:http' -import { type AddressInfo } from 'node:net' +import { type AddressInfo, type Socket } from 'node:net' import { createServerAdapter } from '@whatwg-node/server' import pDefer from 'p-defer' @@ -56,7 +56,7 @@ export function createServer(options: ServerOptions) { options.keyPair = Agent.keyPair() } - const deferredListen = pDefer() + let deferredListen = pDefer() const context = new Context({ ...options, keyPair: options.keyPair, @@ -86,8 +86,8 @@ export function createServer(options: ServerOptions) { }) // Track connections for proper cleanup - const connections = new Set() - const onConnection = (socket: any) => { + const connections = new Set() + const onConnection = (socket: Socket) => { connections.add(socket) socket.once('close', () => { connections.delete(socket) @@ -124,6 +124,8 @@ export function createServer(options: ServerOptions) { once(localHttpServer, 'close'), once(secretStreamServer, 'close'), ]) + // Reset deferred listen for potential restart with different ports + deferredListen = pDefer() }, } } diff --git a/test/server-restart.test.ts b/test/server-restart.test.ts new file mode 100644 index 0000000..3b6665e --- /dev/null +++ b/test/server-restart.test.ts @@ -0,0 +1,141 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { Agent as SecretStreamAgent } from 'secret-stream-http' +import { describe, it, expect } from 'vitest' + +import { createServer } from '../src/index.js' +import { DEMOTILES_Z2, OSM_BRIGHT_Z6 } from './helpers.js' + +describe('Server Restart', () => { + it('should use new ports when restarted with different ports', async (t) => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'map-server-test-')) + const tmpCustomMapPath = path.join(tmpDir, 'custom-map.smp') + await fs.copyFile(OSM_BRIGHT_Z6, tmpCustomMapPath) + + t.onTestFinished(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + const keyPair = SecretStreamAgent.keyPair() + const receiverKeyPair = SecretStreamAgent.keyPair(Buffer.alloc(32, 1)) + const receiverDeviceId = Buffer.from(receiverKeyPair.publicKey).toString( + 'hex', + ) + + const server = createServer({ + defaultOnlineStyleUrl: 'https://demotiles.maplibre.org/style.json', + customMapPath: tmpCustomMapPath, + fallbackMapPath: DEMOTILES_Z2, + keyPair, + }) + + // First listen - use specific ports + const firstResult = await server.listen({ + localPort: 0, + remotePort: 0, + }) + const firstLocalPort = firstResult.localPort + const firstRemotePort = firstResult.remotePort + + // Create a map share to capture the remote port being used + const firstShareResponse = await fetch( + `http://127.0.0.1:${firstLocalPort}/mapShares`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mapId: 'custom', + receiverDeviceId, + }), + }, + ) + expect(firstShareResponse.status).toBe(201) + const firstShare = await firstShareResponse.json() + + // The mapShareUrls should contain the first remote port + expect(firstShare.mapShareUrls).toBeDefined() + expect(firstShare.mapShareUrls.length).toBeGreaterThan(0) + const firstUrl = new URL(firstShare.mapShareUrls[0]) + expect(firstUrl.port).toBe(String(firstRemotePort)) + + // Close the server + await server.close() + + // Listen again - ports will be different (since we use 0) + const secondResult = await server.listen({ + localPort: 0, + remotePort: 0, + }) + const secondLocalPort = secondResult.localPort + const secondRemotePort = secondResult.remotePort + + // Ports should be different (very likely with port 0) + // At minimum, the listen result should reflect the new ports + expect(secondResult.localPort).not.toBe(firstLocalPort) + + // Create another map share - this should use the NEW remote port + const secondShareResponse = await fetch( + `http://127.0.0.1:${secondLocalPort}/mapShares`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mapId: 'custom', + receiverDeviceId, + }), + }, + ) + expect(secondShareResponse.status).toBe(201) + const secondShare = await secondShareResponse.json() + + // The mapShareUrls should contain the SECOND remote port, not the first + expect(secondShare.mapShareUrls).toBeDefined() + expect(secondShare.mapShareUrls.length).toBeGreaterThan(0) + const secondUrl = new URL(secondShare.mapShareUrls[0]) + + // This is the key assertion - the URL should have the new port + expect(secondUrl.port).toBe(String(secondRemotePort)) + expect(secondUrl.port).not.toBe(String(firstRemotePort)) + + // Clean up + await server.close() + }) + + it('should work correctly after multiple restart cycles', async (t) => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'map-server-test-')) + const tmpCustomMapPath = path.join(tmpDir, 'custom-map.smp') + await fs.copyFile(OSM_BRIGHT_Z6, tmpCustomMapPath) + + t.onTestFinished(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + const server = createServer({ + defaultOnlineStyleUrl: 'https://demotiles.maplibre.org/style.json', + customMapPath: tmpCustomMapPath, + fallbackMapPath: DEMOTILES_Z2, + }) + + const ports: number[] = [] + + // Restart 3 times + for (let i = 0; i < 3; i++) { + const result = await server.listen({ localPort: 0, remotePort: 0 }) + ports.push(result.localPort) + + // Verify server is working + const response = await fetch( + `http://127.0.0.1:${result.localPort}/maps/custom/style.json`, + ) + expect(response.status).toBe(200) + + await server.close() + } + + // All ports should be different (extremely likely with port 0) + const uniquePorts = new Set(ports) + expect(uniquePorts.size).toBe(3) + }, 20_000) // On node 18 restarting can be slow. +})