spark-html-bun 0.1.0 → 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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.js +55 -17
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spark-html-bun",
3
- "version": "0.1.0",
4
- "description": "Dev server, build, and preview for spark-html apps built entirely on Bun. Scoped component HMR over a plain WebSocket, import-map dev serving (no bundling in dev), Bun.build for the app shell, and an explicit post-build pipeline (prerender, image, font, manifest, offline, sri). Zero dependencies.",
3
+ "version": "0.1.2",
4
+ "description": "Dev server, build, and preview for spark-html apps \u2014 built entirely on Bun. Scoped component HMR over a plain WebSocket, import-map dev serving (no bundling in dev), Bun.build for the app shell, and an explicit post-build pipeline (prerender, image, font, manifest, offline, sri). Zero dependencies.",
5
5
  "homepage": "https://wilkinnovo.github.io/spark",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -42,10 +42,31 @@
42
42
  * devRoutes?({ config }) → { '/path': { type, body() } }, // dev serving
43
43
  * transformHtml?(html, { dev }) } // dev page injection
44
44
  */
45
- import { join, resolve, extname } from 'node:path';
45
+ import { join, resolve, extname, basename, sep } from 'node:path';
46
46
  import { existsSync, watch, readdirSync, statSync, readFileSync } from 'node:fs';
47
47
  import { rm, mkdir, cp, readFile } from 'node:fs/promises';
48
48
 
49
+ // Resolve `rel` under `base` and refuse anything that escapes it — a static
50
+ // server must never serve outside its root. `..` is normalized away by the URL
51
+ // parser, but `decodeURIComponent` can reintroduce it (e.g. `%2e%2e%2f`), so
52
+ // the check runs on the final resolved path.
53
+ function safeJoin(base, rel) {
54
+ const p = resolve(base, rel);
55
+ return p === base || p.startsWith(base + sep) ? p : null;
56
+ }
57
+
58
+ // URL pathname, decoded — null on malformed percent-encoding (rejected as 400).
59
+ function decodePath(pathname) {
60
+ try { return decodeURIComponent(pathname); } catch { return null; }
61
+ }
62
+
63
+ // One stat instead of existsSync + statSync — this runs per request candidate
64
+ // on the dev/preview hot path.
65
+ function isFile(p) {
66
+ const s = statSync(p, { throwIfNoEntry: false });
67
+ return s !== undefined && s.isFile();
68
+ }
69
+
49
70
  const MIME = {
50
71
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
51
72
  '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
@@ -208,7 +229,8 @@ export async function dev(overrides = {}) {
208
229
  development: true,
209
230
  async fetch(req, srv) {
210
231
  const url = new URL(req.url);
211
- let path = decodeURIComponent(url.pathname);
232
+ const path = decodePath(url.pathname);
233
+ if (path === null) return new Response('Bad request', { status: 400 });
212
234
 
213
235
  // WebSocket channel for scoped component HMR.
214
236
  if (path === '/__spark_hmr') {
@@ -233,11 +255,12 @@ export async function dev(overrides = {}) {
233
255
  }
234
256
 
235
257
  // Static lookup: project root first (index.html, src/…), then publicDir.
258
+ // Each candidate is guarded against escaping its own base (path traversal).
236
259
  const rel = path.replace(/^\/+/, '');
237
- const candidates = [join(projectRoot, rel), join(pub, rel)];
238
260
  let file = null;
239
- for (const c of candidates) {
240
- if (existsSync(c) && statSync(c).isFile()) { file = c; break; }
261
+ for (const b of [projectRoot, pub]) {
262
+ const c = safeJoin(b, rel);
263
+ if (c && isFile(c)) { file = c; break; }
241
264
  }
242
265
 
243
266
  // SPA fallback: extensionless paths serve the app shell (the router
@@ -327,12 +350,17 @@ export async function build(overrides = {}) {
327
350
  const file = clean.startsWith('/') ? join(projectRoot, clean.slice(1)) : join(entryDir, clean);
328
351
  // Only bundle files that live in the PROJECT (src/…) — anything served
329
352
  // from publicDir ships verbatim and its URL already works.
330
- if (existsSync(file) && !file.startsWith(pub + '/')) found.push({ url, file });
353
+ if (existsSync(file) && !file.startsWith(pub + sep)) found.push({ url, file });
331
354
  }
332
355
 
333
356
  if (found.length) {
357
+ // Bun.build DEDUPES duplicate entrypoints (the same file listed twice —
358
+ // or reached via two URL spellings — yields ONE output), so bundle the
359
+ // UNIQUE files and map file → hashed name; never map outputs back by
360
+ // found-index, which would splice the wrong asset URL into the page.
361
+ const files = [...new Set(found.map((f) => f.file))];
334
362
  const result = await Bun.build({
335
- entrypoints: found.map((f) => f.file),
363
+ entrypoints: files,
336
364
  outdir: join(outDir, 'assets'),
337
365
  minify: true,
338
366
  splitting: true,
@@ -345,12 +373,20 @@ export async function build(overrides = {}) {
345
373
  const msgs = (result.logs || []).map((l) => l.message || String(l)).join('\n');
346
374
  throw new Error(`[spark] build failed:\n${msgs}`);
347
375
  }
348
- // Entry outputs come back in entrypoint order map each to its URL.
376
+ // Entry outputs come back in entrypoint order (verified for Bun's
377
+ // splitting output, incl. same-basename entries) — map each unique
378
+ // file to its hashed name, then rewrite every tag that referenced it.
349
379
  const entryOuts = result.outputs.filter((o) => o.kind === 'entry-point');
350
- found.forEach((f, i) => {
351
- const name = entryOuts[i] && entryOuts[i].path.split('/').pop();
352
- if (name) html = html.replaceAll(f.url, `${base}assets/${name}`);
353
- });
380
+ const outName = new Map(files.map((file, i) => [file, entryOuts[i] && basename(entryOuts[i].path)]));
381
+ for (const f of found) {
382
+ const name = outName.get(f.file);
383
+ if (!name) continue;
384
+ const to = `${base}assets/${name}`;
385
+ // Replace only the quoted attribute value, so a longer URL that ends
386
+ // with this one (e.g. /lib/app.js vs /app.js) is never corrupted the
387
+ // way a bare replaceAll(url) would corrupt it.
388
+ for (const q of ['"', "'"]) html = html.split(`${q}${f.url}${q}`).join(`${q}${to}${q}`);
389
+ }
354
390
  }
355
391
  await Bun.write(join(outDir, config.entry.split('/').pop()), html);
356
392
  }
@@ -378,16 +414,18 @@ export async function preview(overrides = {}) {
378
414
  port: overrides.port ?? config.port ?? 4173,
379
415
  fetch(req) {
380
416
  const url = new URL(req.url);
381
- let path = decodeURIComponent(url.pathname);
417
+ let path = decodePath(url.pathname);
418
+ if (path === null) return new Response('Bad request', { status: 400 });
382
419
  if (base !== '/' && path.startsWith(base)) path = '/' + path.slice(base.length);
383
420
  const rel = path.replace(/^\/+/, '');
421
+ // Guard every candidate against escaping outDir (path traversal).
384
422
  const tryFiles = [
385
- join(outDir, rel === '' ? 'index.html' : rel),
386
- join(outDir, rel + '.html'),
387
- rel !== '' && rel.endsWith('/') ? join(outDir, rel, 'index.html') : null,
423
+ safeJoin(outDir, rel === '' ? 'index.html' : rel),
424
+ safeJoin(outDir, rel + '.html'),
425
+ rel !== '' && rel.endsWith('/') ? safeJoin(outDir, join(rel, 'index.html')) : null,
388
426
  ].filter(Boolean);
389
427
  for (const f of tryFiles) {
390
- if (existsSync(f) && statSync(f).isFile()) {
428
+ if (isFile(f)) {
391
429
  return new Response(Bun.file(f), { headers: { 'Content-Type': mime(f) } });
392
430
  }
393
431
  }