@looop-games/cli 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ // A standalone game project is a folder with an index.html and a package.json
2
+ // that depends on @looop-games/engine. The CLI must find the project root from any
3
+ // cwd inside it, derive the slug (folder name unless package.json overrides),
4
+ // and locate the installed engine bundle.
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { findProject, resolveEngine } from './project.mjs';
11
+
12
+ function scaffold({ name = 'my-game', looop = undefined } = {}) {
13
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'looop-proj-')));
14
+ const game = join(root, name);
15
+ mkdirSync(game, { recursive: true });
16
+ writeFileSync(join(game, 'index.html'), '<html><head></head><body></body></html>');
17
+ writeFileSync(
18
+ join(game, 'package.json'),
19
+ JSON.stringify({ name, private: true, ...(looop ? { looop } : {}), dependencies: { '@looop-games/engine': '*' } }),
20
+ );
21
+ return { root, game };
22
+ }
23
+
24
+ test('finds the project from the game folder and derives slug from folder name', () => {
25
+ const { root, game } = scaffold({ name: 'tower-jump' });
26
+ try {
27
+ const p = findProject(game);
28
+ assert.equal(p.dir, game);
29
+ assert.equal(p.slug, 'tower-jump');
30
+ } finally {
31
+ rmSync(root, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ test('finds the project from a subdirectory', () => {
36
+ const { root, game } = scaffold();
37
+ try {
38
+ const sub = join(game, 'assets/sprites');
39
+ mkdirSync(sub, { recursive: true });
40
+ assert.equal(findProject(sub).dir, game);
41
+ } finally {
42
+ rmSync(root, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test('package.json looop.slug overrides the folder name', () => {
47
+ const { root, game } = scaffold({ name: 'my-game-repo', looop: { slug: 'basket' } });
48
+ try {
49
+ assert.equal(findProject(game).slug, 'basket');
50
+ } finally {
51
+ rmSync(root, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('throws a clear error outside any project', () => {
56
+ const empty = mkdtempSync(join(tmpdir(), 'looop-empty-'));
57
+ try {
58
+ assert.throws(() => findProject(empty), /Not inside a Looop game project/);
59
+ } finally {
60
+ rmSync(empty, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ test('resolveEngine finds the installed @looop-games/engine bundle', () => {
65
+ const { root, game } = scaffold();
66
+ try {
67
+ const engineDir = join(game, 'node_modules/@looop-games/engine');
68
+ mkdirSync(join(engineDir, 'shared/platform'), { recursive: true });
69
+ mkdirSync(join(engineDir, 'room-server'), { recursive: true });
70
+ writeFileSync(join(engineDir, 'package.json'), JSON.stringify({ name: '@looop-games/engine', version: '0.1.0' }));
71
+ const e = resolveEngine(game);
72
+ assert.equal(e.dir, engineDir);
73
+ assert.equal(e.version, '0.1.0');
74
+ } finally {
75
+ rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('resolveEngine gives an actionable error when the engine is not installed', () => {
80
+ const { root, game } = scaffold();
81
+ try {
82
+ // Q4 revision: the fix is running the CLI (which downloads the engine),
83
+ // not npm install — the engine is no longer an npm dependency.
84
+ assert.throws(() => resolveEngine(game), /engine is not installed.*looop dev/s);
85
+ } finally {
86
+ rmSync(root, { recursive: true, force: true });
87
+ }
88
+ });
@@ -0,0 +1,91 @@
1
+ // `looop publish` — ship the game to play.looop.games (cuqfzo Slice 2, Q1).
2
+ //
3
+ // Server-resolved: we send ONLY the game's own files plus the engine version
4
+ // the installed bundle declares; the platform resolves /shared/... from its
5
+ // release registry. Compare catalog_publish.py, which staged the whole shared
6
+ // library client-side — that trust hole is what this replaces.
7
+ //
8
+ // Target: /api/creator/publish — the token-authenticated creator lane, the
9
+ // one publish route reachable on the public play host (the legacy
10
+ // /api/publish stays behind the builder's Access wall). The endpoint
11
+ // requires the creator token; without one it 401s with a login hint.
12
+ import { createHash } from 'node:crypto';
13
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
14
+ import { join, relative } from 'node:path';
15
+ import { findProject } from './project.mjs';
16
+ import { ensureEngine } from './engine.mjs';
17
+ import { getToken, getApiBase } from './config.mjs';
18
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
19
+
20
+ export const PLAY_BASE = 'https://play.looop.games';
21
+
22
+ // Never shipped: tooling, VCS, agent workspace, notes (they travel with the
23
+ // repo, not the catalog — same rule as the monorepo publisher), tests/smokes.
24
+ const SKIP_DIRS = new Set(['node_modules', 'notes', '.git', '.looop', '.claude', '__pycache__']);
25
+ const SKIP_FILES = [
26
+ /^package(-lock)?\.json$/,
27
+ /\.(test|smoke)\.(js|mjs|ts)$/,
28
+ /^\.DS_Store$/,
29
+ /^gamedev\.toml$/,
30
+ ];
31
+
32
+ export function collectGameFiles(dir) {
33
+ const out = new Map(); // relative path → Buffer
34
+ const walk = (d) => {
35
+ for (const name of readdirSync(d)) {
36
+ const p = join(d, name);
37
+ const rel = relative(dir, p).split('\\').join('/');
38
+ if (statSync(p).isDirectory()) {
39
+ if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
40
+ walk(p);
41
+ } else {
42
+ if (name.startsWith('.') || SKIP_FILES.some((re) => re.test(name))) continue;
43
+ out.set(rel, readFileSync(p));
44
+ }
45
+ }
46
+ };
47
+ walk(dir);
48
+ return out;
49
+ }
50
+
51
+ export async function publish({
52
+ cwd = process.cwd(),
53
+ apiBase = getApiBase(DEFAULT_API_BASE),
54
+ slug,
55
+ log = console.log,
56
+ } = {}) {
57
+ const project = findProject(cwd);
58
+ // The declared engineVersion must be a version the platform can resolve —
59
+ // ensureEngine keeps the local install synced to the game's pin.
60
+ const engine = await ensureEngine(project.dir, { apiBase, log });
61
+ const targetSlug = slug ?? project.slug;
62
+
63
+ const files = collectGameFiles(project.dir);
64
+ if (!files.has('index.html')) {
65
+ throw new Error('this game has no index.html at its root — publish needs an entry page.');
66
+ }
67
+
68
+ const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, files: {} };
69
+ const form = new FormData();
70
+ for (const [path, bytes] of files) {
71
+ meta.files[path] = createHash('sha256').update(bytes).digest('hex');
72
+ form.append(path, new Blob([bytes]), path);
73
+ }
74
+ form.append('meta', JSON.stringify(meta));
75
+
76
+ log(`Publishing ${targetSlug} (${files.size} files, engine ${engine.version}) → ${apiBase}…`);
77
+ const token = getToken();
78
+ const res = await fetch(`${apiBase}/api/creator/publish`, {
79
+ method: 'POST',
80
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
81
+ body: form,
82
+ });
83
+ if (!res.ok) {
84
+ const detail = await res.text().catch(() => '');
85
+ throw new Error(`publish failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
86
+ }
87
+ const out = await res.json();
88
+ const url = `${PLAY_BASE}/g/${out.slug}`;
89
+ log(`✅ Live: ${url}`);
90
+ return { slug: out.slug, url };
91
+ }
@@ -0,0 +1,104 @@
1
+ // `looop publish` — the client half of server-resolved publish (cuqfzo
2
+ // Slice 2, Q1). Against a mock platform capturing the multipart body, pins
3
+ // the wire contract: ONLY game files ship (never shared/, node_modules,
4
+ // notes/, tests, dotfiles), engineVersion is declared from the installed
5
+ // bundle, hashes are real sha256s, overrides ride along as game files, and
6
+ // the stored creator token goes out as a Bearer header.
7
+ import { test, after } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import http from 'node:http';
10
+ import { createHash } from 'node:crypto';
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { publish } from './publish.mjs';
15
+ import { setToken } from './config.mjs';
16
+
17
+ process.env.LOOOP_HOME = mkdtempSync(join(tmpdir(), 'looop-pub-home-'));
18
+
19
+ // ── mock platform ──
20
+ let captured = null;
21
+ const server = http.createServer(async (req, res) => {
22
+ const chunks = [];
23
+ for await (const c of req) chunks.push(c);
24
+ const body = Buffer.concat(chunks);
25
+ // Parse the multipart with the same machinery the real endpoint uses.
26
+ const parsed = await new Request('http://x/api/publish', {
27
+ method: 'POST',
28
+ headers: { 'content-type': req.headers['content-type'] },
29
+ body,
30
+ }).formData();
31
+ const parts = {};
32
+ for (const [name, value] of parsed.entries()) {
33
+ parts[name] = typeof value === 'string' ? value : Buffer.from(await value.arrayBuffer()).toString('utf8');
34
+ }
35
+ captured = { url: req.url, auth: req.headers.authorization ?? null, parts, meta: JSON.parse(parts.meta) };
36
+ res.writeHead(200, { 'Content-Type': 'application/json' });
37
+ res.end(JSON.stringify({ slug: captured.meta.slug ?? 'minted-abc12', title: 'T', createdAt: 1, updatedAt: 2 }));
38
+ });
39
+ await new Promise((r) => server.listen(0, r));
40
+ const apiBase = `http://localhost:${server.address().port}`;
41
+
42
+ // ── fixture standalone game ──
43
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'looop-pub-')));
44
+ const game = join(root, 'my-game');
45
+ mkdirSync(join(game, 'node_modules/@looop-games/engine'), { recursive: true });
46
+ mkdirSync(join(game, 'notes'), { recursive: true });
47
+ mkdirSync(join(game, 'overrides/shared/ui/room'), { recursive: true });
48
+ mkdirSync(join(game, 'assets'), { recursive: true });
49
+ writeFileSync(join(game, 'index.html'), '<html><head><title>My Game</title></head></html>');
50
+ writeFileSync(join(game, 'world.js'), 'export const w = 1;');
51
+ writeFileSync(join(game, 'assets/s.png'), 'png-bytes');
52
+ writeFileSync(join(game, 'overrides/shared/ui/room/x.js'), 'export const x = 2;');
53
+ writeFileSync(join(game, 'notes/design.md'), 'secret notes');
54
+ writeFileSync(join(game, 'my-game.smoke.mjs'), '// test file');
55
+ writeFileSync(join(game, '.gitignore'), 'node_modules');
56
+ writeFileSync(join(game, 'package.json'), JSON.stringify({ name: 'my-game', dependencies: { '@looop-games/engine': '*' } }));
57
+ writeFileSync(
58
+ join(game, 'node_modules/@looop-games/engine/package.json'),
59
+ JSON.stringify({ name: '@looop-games/engine', version: '0.1.0' }),
60
+ );
61
+ writeFileSync(join(game, 'node_modules/@looop-games/engine/junk.js'), 'never ship');
62
+
63
+ after(() => {
64
+ server.close();
65
+ rmSync(root, { recursive: true, force: true });
66
+ rmSync(process.env.LOOOP_HOME, { recursive: true, force: true });
67
+ });
68
+
69
+ test('publishes only the game files, with engineVersion and real hashes', async () => {
70
+ setToken('looop_' + '1'.repeat(64), { apiBase });
71
+ const result = await publish({ cwd: game, apiBase, log: () => {} });
72
+ assert.equal(result.slug, 'my-game');
73
+ assert.equal(result.url, 'https://play.looop.games/g/my-game');
74
+
75
+ const { url, meta, parts, auth } = captured;
76
+ // The creator lane — the one publish route on the public play host. The
77
+ // legacy /api/publish stays behind the builder's Access wall.
78
+ assert.equal(url, '/api/creator/publish');
79
+ assert.equal(meta.engineVersion, '0.1.0');
80
+ assert.equal(meta.entry, 'index.html');
81
+ assert.equal(meta.slug, 'my-game');
82
+ const paths = Object.keys(meta.files).sort();
83
+ assert.deepEqual(paths, ['assets/s.png', 'index.html', 'overrides/shared/ui/room/x.js', 'world.js']);
84
+ // Real sha256 of the actual bytes, and each file rides as its own part.
85
+ const expected = createHash('sha256').update('export const w = 1;').digest('hex');
86
+ assert.equal(meta.files['world.js'], expected);
87
+ assert.equal(parts['world.js'], 'export const w = 1;');
88
+ // The stored creator token authenticates the upload.
89
+ assert.equal(auth, `Bearer looop_${'1'.repeat(64)}`);
90
+ });
91
+
92
+ test('--slug publishes a parallel copy without touching the canonical slug', async () => {
93
+ const result = await publish({ cwd: game, apiBase, slug: 'my-game-exp', log: () => {} });
94
+ assert.equal(result.slug, 'my-game-exp');
95
+ assert.equal(captured.meta.slug, 'my-game-exp');
96
+ });
97
+
98
+ test('a game without index.html is rejected before any upload', async () => {
99
+ const bare = join(root, 'no-entry');
100
+ mkdirSync(bare);
101
+ writeFileSync(join(bare, 'package.json'), '{}');
102
+ writeFileSync(join(bare, 'main.js'), '// no entry');
103
+ await assert.rejects(() => publish({ cwd: bare, apiBase, log: () => {} }), /index\.html/);
104
+ });
@@ -0,0 +1,260 @@
1
+ // The standalone dev static server — Node port of looop-core
2
+ // tools/game/dev_server.py, serving a single game + the installed engine
3
+ // bundle behind the same URL shape production uses:
4
+ //
5
+ // /games/<slug>/… → the game folder
6
+ // /shared/… → the engine bundle's shared/ tree
7
+ // /__reload → SSE; pings open tabs when a watched file changes
8
+ //
9
+ // Mounts are ORDERED — first prefix match wins. That ordering is how
10
+ // overrides/shared/… will shadow the engine bundle (Slice 2): the game's
11
+ // overrides dir mounts at /shared/ ahead of the bundle.
12
+ import http from 'node:http';
13
+ import { EventEmitter } from 'node:events';
14
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
15
+ import { extname, join, normalize, sep } from 'node:path';
16
+ import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL } from './inject.mjs';
17
+
18
+ const RELOAD_CLIENT = `<script>
19
+ (() => {
20
+ let alive = false;
21
+ function connect() {
22
+ const es = new EventSource('/__reload');
23
+ es.onopen = () => { alive = true; };
24
+ es.onmessage = (e) => { if (e.data === 'reload') location.reload(); };
25
+ es.onerror = () => {
26
+ es.close();
27
+ setTimeout(connect, alive ? 200 : 1000);
28
+ alive = false;
29
+ };
30
+ }
31
+ connect();
32
+ })();
33
+ </script>`;
34
+
35
+ const WATCH_EXTS = new Set(['.html', '.css', '.js', '.mjs', '.json', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp']);
36
+
37
+ const MIME = {
38
+ '.html': 'text/html; charset=utf-8',
39
+ '.js': 'text/javascript; charset=utf-8',
40
+ '.mjs': 'text/javascript; charset=utf-8',
41
+ '.css': 'text/css; charset=utf-8',
42
+ '.json': 'application/json; charset=utf-8',
43
+ '.svg': 'image/svg+xml',
44
+ '.png': 'image/png',
45
+ '.jpg': 'image/jpeg',
46
+ '.jpeg': 'image/jpeg',
47
+ '.gif': 'image/gif',
48
+ '.webp': 'image/webp',
49
+ '.ico': 'image/x-icon',
50
+ '.wasm': 'application/wasm',
51
+ '.mp3': 'audio/mpeg',
52
+ '.ogg': 'audio/ogg',
53
+ '.wav': 'audio/wav',
54
+ '.txt': 'text/plain; charset=utf-8',
55
+ '.md': 'text/markdown; charset=utf-8',
56
+ '.woff': 'font/woff',
57
+ '.woff2': 'font/woff2',
58
+ '.glb': 'model/gltf-binary',
59
+ '.gltf': 'model/gltf+json',
60
+ };
61
+
62
+ function collectMtimes(roots) {
63
+ const out = new Map();
64
+ for (const root of roots) {
65
+ if (!existsSync(root)) continue;
66
+ const walk = (dir) => {
67
+ let names;
68
+ try {
69
+ names = readdirSync(dir);
70
+ } catch {
71
+ return;
72
+ }
73
+ for (const name of names) {
74
+ if (name.startsWith('.') || name === 'node_modules') continue;
75
+ const p = join(dir, name);
76
+ let st;
77
+ try {
78
+ st = statSync(p);
79
+ } catch {
80
+ continue;
81
+ }
82
+ if (st.isDirectory()) walk(p);
83
+ else if (WATCH_EXTS.has(extname(name).toLowerCase())) out.set(p, st.mtimeMs);
84
+ }
85
+ };
86
+ walk(root);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function mtimesChanged(a, b) {
92
+ if (a.size !== b.size) return true;
93
+ for (const [k, v] of a) if (b.get(k) !== v) return true;
94
+ return false;
95
+ }
96
+
97
+ export function createStaticServer({
98
+ slug,
99
+ mounts,
100
+ watchDirs = [],
101
+ injectReload = true,
102
+ watchIntervalMs = 400,
103
+ identity,
104
+ }) {
105
+ // Normalize mounts: url ends with '/', dir has no trailing separator.
106
+ const table = mounts.map(({ url, dir }) => ({
107
+ url: url.endsWith('/') ? url : url + '/',
108
+ dir: normalize(dir),
109
+ }));
110
+
111
+ // URL path → filesystem path. Mounts are tried in order and FALL THROUGH:
112
+ // the first mount that actually contains the file wins. That ordering is
113
+ // the overrides mechanism (Slice 2): the game's overrides/shared/ dir
114
+ // mounts at /shared/ ahead of the engine bundle, shadowing per-file.
115
+ function resolveUrl(urlPath) {
116
+ let decoded;
117
+ try {
118
+ decoded = decodeURIComponent(urlPath);
119
+ } catch {
120
+ return null;
121
+ }
122
+ for (const { url, dir } of table) {
123
+ if (!decoded.startsWith(url)) continue;
124
+ const rest = decoded.slice(url.length).replace(/^\/+/, '');
125
+ const resolved = normalize(join(dir, rest));
126
+ if (resolved !== dir && !resolved.startsWith(dir + sep)) return null; // traversal escape
127
+ if (existsSync(resolved)) return resolved;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ const reload = new EventEmitter();
133
+ reload.setMaxListeners(0);
134
+ let watchTimer = null;
135
+ if (injectReload && watchDirs.length) {
136
+ let snapshot = collectMtimes(watchDirs);
137
+ watchTimer = setInterval(() => {
138
+ const next = collectMtimes(watchDirs);
139
+ if (mtimesChanged(snapshot, next)) {
140
+ snapshot = next;
141
+ reload.emit('reload');
142
+ }
143
+ }, watchIntervalMs);
144
+ watchTimer.unref();
145
+ }
146
+
147
+ const gameEntry = `/games/${slug}/index.html`;
148
+
149
+ function serveHtml(res, fsPath, urlPath, query) {
150
+ let html = readFileSync(fsPath, 'utf8');
151
+ const baseDir = join(fsPath, '..');
152
+ html = rewriteHtmlScripts(html, { baseDir, resolveUrl });
153
+ // Mirror the production /g/<slug> entry injection — only when the resolved
154
+ // engine actually ships the platform layer (transition shim, same as
155
+ // dev_server.py _engine_has_platform).
156
+ if (resolveUrl(PLATFORM_URL)) {
157
+ const m = /^\/games\/([^/]+)\//.exec(urlPath);
158
+ // ?as=<name>: a distinct dev identity for this tab. The room dedups
159
+ // same-account connections even for unverified dev claims, so two tabs
160
+ // as the constant dev identity evict each other — ?as= is how a human
161
+ // is two players in local MP testing. Sanitized to the slug charset so
162
+ // the value can't escape the inline script.
163
+ const as = query?.get('as')?.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
164
+ const tabIdentity = as
165
+ ? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
166
+ : identity;
167
+ html = injectHeadTags(html, m ? m[1] : null, tabIdentity ? { identity: tabIdentity } : {});
168
+ }
169
+ if (injectReload) {
170
+ html = html.includes('</body>') ? html.replace('</body>', RELOAD_CLIENT + '</body>') : html + RELOAD_CLIENT;
171
+ }
172
+ sendBody(res, 200, html, MIME['.html']);
173
+ }
174
+
175
+ function serveJs(res, fsPath) {
176
+ const raw = readFileSync(fsPath);
177
+ let body;
178
+ try {
179
+ body = rewriteJsImports(raw.toString('utf8'), { baseDir: join(fsPath, '..'), resolveUrl });
180
+ } catch {
181
+ body = raw;
182
+ }
183
+ sendBody(res, 200, body, MIME['.js']);
184
+ }
185
+
186
+ function sendBody(res, status, body, type) {
187
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
188
+ res.writeHead(status, {
189
+ 'Content-Type': type,
190
+ 'Content-Length': buf.length,
191
+ 'Cache-Control': 'no-store, must-revalidate',
192
+ 'Access-Control-Allow-Origin': '*',
193
+ });
194
+ res.end(buf);
195
+ }
196
+
197
+ function handleSse(res) {
198
+ res.writeHead(200, {
199
+ 'Content-Type': 'text/event-stream',
200
+ 'Cache-Control': 'no-cache',
201
+ Connection: 'keep-alive',
202
+ });
203
+ res.write(':connected\n\n');
204
+ const push = () => res.write('data: reload\n\n');
205
+ const keepalive = setInterval(() => res.write(':keepalive\n\n'), 10_000);
206
+ keepalive.unref();
207
+ reload.on('reload', push);
208
+ res.on('close', () => {
209
+ clearInterval(keepalive);
210
+ reload.off('reload', push);
211
+ });
212
+ }
213
+
214
+ const server = http.createServer((req, res) => {
215
+ const reqUrl = new URL(req.url, 'http://x');
216
+ const urlPath = reqUrl.pathname;
217
+ if (urlPath === '/__reload') return handleSse(res);
218
+ if (urlPath === '/' || urlPath === '/index.html') {
219
+ res.writeHead(302, { Location: gameEntry });
220
+ return res.end();
221
+ }
222
+ let fsPath = resolveUrl(urlPath);
223
+ if (fsPath && statSync(fsPath).isDirectory()) {
224
+ const index = join(fsPath, 'index.html');
225
+ fsPath = urlPath.endsWith('/') && existsSync(index) ? index : null;
226
+ }
227
+ if (!fsPath) {
228
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
229
+ return res.end('404');
230
+ }
231
+ const ext = extname(fsPath).toLowerCase();
232
+ try {
233
+ if (ext === '.html') return serveHtml(res, fsPath, urlPath, reqUrl.searchParams);
234
+ if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath);
235
+ sendBody(res, 200, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream');
236
+ } catch {
237
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
238
+ res.end('500');
239
+ }
240
+ });
241
+
242
+ return {
243
+ server,
244
+ get port() {
245
+ return server.address()?.port;
246
+ },
247
+ resolveUrl,
248
+ listen(port, bind = '0.0.0.0') {
249
+ return new Promise((resolveP, rejectP) => {
250
+ server.once('error', rejectP);
251
+ server.listen(port, bind, () => resolveP(server.address().port));
252
+ });
253
+ },
254
+ close() {
255
+ if (watchTimer) clearInterval(watchTimer);
256
+ server.close();
257
+ server.closeAllConnections?.();
258
+ },
259
+ };
260
+ }