spark-html-bun 0.1.0 → 0.1.1

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 +1 -1
  2. package/src/index.js +36 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spark-html-bun",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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.",
5
5
  "homepage": "https://wilkinnovo.github.io/spark",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -42,10 +42,24 @@
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
+
49
63
  const MIME = {
50
64
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
51
65
  '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
@@ -208,7 +222,8 @@ export async function dev(overrides = {}) {
208
222
  development: true,
209
223
  async fetch(req, srv) {
210
224
  const url = new URL(req.url);
211
- let path = decodeURIComponent(url.pathname);
225
+ const path = decodePath(url.pathname);
226
+ if (path === null) return new Response('Bad request', { status: 400 });
212
227
 
213
228
  // WebSocket channel for scoped component HMR.
214
229
  if (path === '/__spark_hmr') {
@@ -233,11 +248,12 @@ export async function dev(overrides = {}) {
233
248
  }
234
249
 
235
250
  // Static lookup: project root first (index.html, src/…), then publicDir.
251
+ // Each candidate is guarded against escaping its own base (path traversal).
236
252
  const rel = path.replace(/^\/+/, '');
237
- const candidates = [join(projectRoot, rel), join(pub, rel)];
238
253
  let file = null;
239
- for (const c of candidates) {
240
- if (existsSync(c) && statSync(c).isFile()) { file = c; break; }
254
+ for (const b of [projectRoot, pub]) {
255
+ const c = safeJoin(b, rel);
256
+ if (c && existsSync(c) && statSync(c).isFile()) { file = c; break; }
241
257
  }
242
258
 
243
259
  // SPA fallback: extensionless paths serve the app shell (the router
@@ -345,11 +361,17 @@ export async function build(overrides = {}) {
345
361
  const msgs = (result.logs || []).map((l) => l.message || String(l)).join('\n');
346
362
  throw new Error(`[spark] build failed:\n${msgs}`);
347
363
  }
348
- // Entry outputs come back in entrypoint order map each to its URL.
364
+ // Entry outputs come back in entrypoint order (verified for Bun's
365
+ // splitting output, incl. same-basename entries) — map each to its URL.
349
366
  const entryOuts = result.outputs.filter((o) => o.kind === 'entry-point');
350
367
  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}`);
368
+ const name = entryOuts[i] && basename(entryOuts[i].path);
369
+ if (!name) return;
370
+ const to = `${base}assets/${name}`;
371
+ // Replace only the quoted attribute value, so a longer URL that ends
372
+ // with this one (e.g. /lib/app.js vs /app.js) is never corrupted the
373
+ // way a bare replaceAll(url) would corrupt it.
374
+ for (const q of ['"', "'"]) html = html.split(`${q}${f.url}${q}`).join(`${q}${to}${q}`);
353
375
  });
354
376
  }
355
377
  await Bun.write(join(outDir, config.entry.split('/').pop()), html);
@@ -378,13 +400,15 @@ export async function preview(overrides = {}) {
378
400
  port: overrides.port ?? config.port ?? 4173,
379
401
  fetch(req) {
380
402
  const url = new URL(req.url);
381
- let path = decodeURIComponent(url.pathname);
403
+ let path = decodePath(url.pathname);
404
+ if (path === null) return new Response('Bad request', { status: 400 });
382
405
  if (base !== '/' && path.startsWith(base)) path = '/' + path.slice(base.length);
383
406
  const rel = path.replace(/^\/+/, '');
407
+ // Guard every candidate against escaping outDir (path traversal).
384
408
  const tryFiles = [
385
- join(outDir, rel === '' ? 'index.html' : rel),
386
- join(outDir, rel + '.html'),
387
- rel !== '' && rel.endsWith('/') ? join(outDir, rel, 'index.html') : null,
409
+ safeJoin(outDir, rel === '' ? 'index.html' : rel),
410
+ safeJoin(outDir, rel + '.html'),
411
+ rel !== '' && rel.endsWith('/') ? safeJoin(outDir, join(rel, 'index.html')) : null,
388
412
  ].filter(Boolean);
389
413
  for (const f of tryFiles) {
390
414
  if (existsSync(f) && statSync(f).isFile()) {