(opts: StaticServerOptions)
| 36 | * 404 rather than falling back, so a bad asset never gets served as HTML. |
| 37 | */ |
| 38 | export async function startStaticServer(opts: StaticServerOptions): Promise<{ port: number }> { |
| 39 | const spaRoot = path.resolve(opts.spaRoot); |
| 40 | const indexHtml = path.join(spaRoot, "index.html"); |
| 41 | |
| 42 | const server = createServer((req, res) => { |
| 43 | void (async () => { |
| 44 | if (await handleFileRequest(req, res, opts.allowedRoots)) return; |
| 45 | |
| 46 | const url = new URL(req.url ?? "/", "http://localhost"); |
| 47 | const pathname = decodeURIComponent(url.pathname); |
| 48 | |
| 49 | // Resolve under spaRoot; reject any traversal escape. |
| 50 | const target = path.resolve(spaRoot, "." + pathname); |
| 51 | const rel = path.relative(spaRoot, target); |
| 52 | if (rel.startsWith("..") || path.isAbsolute(rel)) { |
| 53 | res.statusCode = 403; |
| 54 | res.end("forbidden"); |
| 55 | return; |
| 56 | } |
| 57 | |
| 58 | const isAssetPath = pathname.startsWith("/assets/"); |
| 59 | const served = await serveFile(res, pathname === "/" ? indexHtml : target); |
| 60 | if (served) return; |
| 61 | |
| 62 | // Real asset misses are 404s; anything else is an SPA route → index.html. |
| 63 | if (isAssetPath) { |
| 64 | res.statusCode = 404; |
| 65 | res.end("not found"); |
| 66 | return; |
| 67 | } |
| 68 | if (!(await serveFile(res, indexHtml))) { |
| 69 | res.statusCode = 404; |
| 70 | res.end("not found"); |
| 71 | } |
| 72 | })(); |
| 73 | }); |
| 74 | |
| 75 | return new Promise((resolve, reject) => { |
| 76 | server.on("error", reject); |
| 77 | server.listen(0, "127.0.0.1", () => { |
| 78 | const { port } = server.address() as AddressInfo; |
| 79 | resolve({ port }); |
| 80 | }); |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | /** Write `file` to the response with a guessed content type. Returns false if it doesn't exist. */ |
| 85 | async function serveFile(res: ServerResponseLike, file: string): Promise<boolean> { |
no test coverage detected