Merge remote-tracking branch 'origin/master' into worktree/web-ask-user-question

# Conflicts:
#	apps/web/tests/smoke-fixture.e2e.ts
This commit is contained in:
Yichen Jiang
2026-07-22 23:48:53 +08:00
622 changed files with 29208 additions and 3469 deletions

View File

@@ -2,7 +2,7 @@
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No TLS, auth, or origin policy** — the server binds `0.0.0.0` and trusts its network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
- **`port` is the only listen knob** — bind address and socket options are fixed until a deployment needs them.
- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.

View File

@@ -10,6 +10,7 @@
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { dirname } from 'node:path'
import { serveStatic } from './static.ts'
import type { HostWebPluginRegistry } from './web-plugins.ts'
@@ -21,7 +22,9 @@ export type {
/** Options for startWebServer. */
export interface WebServerOptions {
/** Port to listen on (0.0.0.0). */
/** Address or hostname to listen on. */
host: string
/** Port to listen on; zero requests an OS-assigned port. */
port: number
/**
* Absolute path of index.html inside the static root — the caller resolves
@@ -40,7 +43,7 @@ export interface WebServerOptions {
/** Listening web server handle. */
export interface RunningWebServer {
/** The listening port (for the shell's URL line; equals options.port). */
/** The listening port, including the OS-assigned value when options.port is zero. */
port: number
/**
* Shutdown: close + closeAllConnections (SSE connections never end on their
@@ -50,7 +53,7 @@ export interface RunningWebServer {
}
/**
* Start the web-shape HTTP server: listen(port, '0.0.0.0').
* Start the web-shape HTTP server on the caller-selected host and port.
* Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
* static with the step1-locked semantics (403 traversal, SPA fallback 200).
* A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
@@ -63,7 +66,7 @@ export interface RunningWebServer {
* @returns the running server handle once listening.
*/
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
const { port, distIndex, apiHandler, webPlugins } = options
const { host, port, distIndex, apiHandler, webPlugins } = options
const distRoot = dirname(distIndex)
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
const html = await readFile(distIndex, 'utf8')
@@ -113,10 +116,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
return new Promise((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(port, '0.0.0.0', () => {
server.listen(port, host, () => {
server.off('error', rejectListen)
server.on('error', onError)
resolveListen({ port, close })
resolveListen({ port: (server.address() as AddressInfo).port, close })
})
})
}

View File

@@ -1,16 +1,16 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { createServer as createNetServer, type AddressInfo } from 'node:net'
import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startWebServer, type RunningWebServer } from '../src/index.ts'
/** RunningWebServer.port echoes options.port, so tests must pick a concrete free port up front. */
/** Reserve a loopback port for tests that need to address a second server. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createNetServer()
probe.once('error', reject)
probe.listen(0, () => {
probe.listen(0, '127.0.0.1', () => {
const port = (probe.address() as AddressInfo).port
probe.close(() => { resolve(port) })
})
@@ -107,16 +107,15 @@ afterEach(async () => {
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, onError)
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
return `http://127.0.0.1:${String(server.port)}`
}
describe('startWebServer', () => {
it('reports the listening port and closes idempotently', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)
expect(server.port).toBe(port)
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
expect(server.port).toBeGreaterThan(0)
const first = server.close()
const second = server.close()
expect(second).toBe(first)
@@ -124,11 +123,33 @@ describe('startWebServer', () => {
server = undefined
})
it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
const { distIndex } = makeDist()
const port = 3080
const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
this: NetServer, ...args: unknown[]
): NetServer {
const callback = args.at(-1)
if (typeof callback !== 'function') throw new TypeError('listen callback missing')
queueMicrotask(callback as () => void)
return this
})
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
try {
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
await inertServer.close()
} finally {
address.mockRestore()
listen.mockRestore()
}
})
it('rejects when the port is already taken', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)
await expect(startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined))
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
.rejects.toMatchObject({ code: 'EADDRINUSE' })
})
})
@@ -185,7 +206,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
}
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined)
server = await startWebServer(
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
return `http://127.0.0.1:${String(server.port)}`
}
@@ -221,7 +244,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
clientPath: () => '/nonexistent/lib/client.js',
}
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined)
server = await startWebServer(
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
expect(res.status).toBe(404)
})