peachsim 0.1.626 → 0.1.628

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 (106) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +3 -2
  3. package/cli/bin.ts +6 -2
  4. package/cli/bridge-flow-runner.ts +79 -72
  5. package/cli/commands/box/bundle-plane.ts +7 -2
  6. package/cli/commands/box.ts +9 -2
  7. package/cli/commands/cleanup.ts +6 -6
  8. package/cli/commands/control.ts +36 -6
  9. package/cli/commands/detox.ts +25 -11
  10. package/cli/commands/flow.ts +20 -8
  11. package/cli/commands/inspect.ts +22 -37
  12. package/cli/commands/maestro-generate.ts +5 -12
  13. package/cli/commands/maestro.ts +16 -6
  14. package/cli/commands/mode.ts +11 -7
  15. package/cli/commands/platform.ts +1 -0
  16. package/cli/commands/report-issue.ts +1 -1
  17. package/cli/commands/setup.ts +16 -11
  18. package/cli/commands/show-touches-mode.ts +33 -0
  19. package/cli/commands/skills.ts +16 -0
  20. package/cli/commands/test.ts +22 -1
  21. package/cli/commands/upload.ts +18 -8
  22. package/cli/commands/welcome.ts +3 -3
  23. package/cli/drivers/playwright-sim-host.ts +16 -8
  24. package/cli/drivers/playwright.ts +8 -5
  25. package/cli/help-core.ts +9 -4
  26. package/cli/internal-child.ts +14 -2
  27. package/cli/levenshtein.ts +27 -0
  28. package/cli/main.ts +28 -18
  29. package/cli/run-rnx.ts +8 -4
  30. package/cli/ws-bridge.ts +3 -2
  31. package/detox/index.ts +234 -7
  32. package/dist-lib/agent-daemon-client.cjs +2 -1
  33. package/dist-lib/agent-events.cjs +1 -1
  34. package/dist-lib/agent-identity.cjs +1 -1
  35. package/dist-lib/agent-sessions.cjs +1 -1
  36. package/dist-lib/attached-projects.cjs +1 -1
  37. package/dist-lib/auth/shared-session.cjs +1 -1
  38. package/dist-lib/backend-origin.cjs +1 -1
  39. package/dist-lib/beta.cjs +1 -1
  40. package/dist-lib/beta.mjs +1 -1
  41. package/dist-lib/bridge-constants.cjs +4 -1
  42. package/dist-lib/bridge-contract-input.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.mjs +1 -1
  44. package/dist-lib/bridge-contract.cjs +1 -1
  45. package/dist-lib/bridge-contract.mjs +1 -1
  46. package/dist-lib/capture-contract.cjs +18 -8
  47. package/dist-lib/capture-contract.mjs +18 -8
  48. package/dist-lib/cli-constants.cjs +1 -1
  49. package/dist-lib/cloud-contract.cjs +1 -1
  50. package/dist-lib/cloud-contract.mjs +1 -1
  51. package/dist-lib/cloud-test-args.cjs +1 -1
  52. package/dist-lib/cloud-test-args.mjs +1 -1
  53. package/dist-lib/cloud.cjs +1 -1
  54. package/dist-lib/cloud.mjs +1 -1
  55. package/dist-lib/config.cjs +1 -1
  56. package/dist-lib/detox/index.cjs +170 -13
  57. package/dist-lib/dev-bundle-resolution.cjs +2 -2
  58. package/dist-lib/home-paths.cjs +1 -1
  59. package/dist-lib/host/bridge-host.cjs +280 -138
  60. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  61. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  62. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  63. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  64. package/dist-lib/host/websocket-proxy.cjs +1 -1
  65. package/dist-lib/index.cjs +62 -12
  66. package/dist-lib/jump-to-source-babel.cjs +1 -1
  67. package/dist-lib/jump-to-source-native.cjs +1 -1
  68. package/dist-lib/menu.cjs +7 -1
  69. package/dist-lib/menu.mjs +7 -1
  70. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  71. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  72. package/dist-lib/metro-production-bundle.cjs +1 -1
  73. package/dist-lib/metro-production-bundle.mjs +1 -1
  74. package/dist-lib/metro.cjs +43 -4
  75. package/dist-lib/profiles.cjs +1 -1
  76. package/dist-lib/public-brand.cjs +1 -1
  77. package/dist-lib/react-native-host-modules.cjs +1 -1
  78. package/dist-lib/react-native-host-modules.mjs +1 -1
  79. package/dist-lib/render-mode.cjs +1 -1
  80. package/dist-lib/scripts/dev-server-scanner.cjs +215 -28
  81. package/dist-lib/sdk.cjs +1 -1
  82. package/dist-lib/sdk.mjs +1 -1
  83. package/dist-lib/skills.cjs +431 -276
  84. package/dist-lib/swift-compile.cjs +1 -1
  85. package/dist-lib/swift-compile.mjs +1 -1
  86. package/dist-lib/swift-package.cjs +1 -1
  87. package/dist-lib/swift-package.mjs +1 -1
  88. package/dist-lib/swift-submit.cjs +1 -1
  89. package/dist-lib/swift-submit.mjs +1 -1
  90. package/dist-lib/swift.cjs +14512 -14208
  91. package/dist-lib/vite-kotlin.cjs +1 -1
  92. package/dist-lib/vite-swift.cjs +1 -1
  93. package/dist-lib/vite.cjs +44 -4
  94. package/install.mjs +16 -0
  95. package/install.sh +199 -0
  96. package/package.json +6 -1
  97. package/scripts/dev-server-scanner.ts +77 -46
  98. package/skills/contrast/SKILL.md +1 -1
  99. package/src/bridge-constants.ts +11 -0
  100. package/src/capture-contract.ts +40 -7
  101. package/src/dev-bundle-resolution.ts +1 -1
  102. package/src/host/bridge-host.ts +26 -137
  103. package/src/menu.ts +7 -0
  104. package/src/native-seam-manifest.ts +4 -16
  105. package/src/runtime-assets.ts +59 -2
  106. package/src/vite-plugin.ts +723 -25
@@ -9,6 +9,7 @@ import {
9
9
  } from '../../scripts/dev-server-scanner.ts'
10
10
  import { buildBrowserAssetProxyUrl } from '../backend-origin.ts'
11
11
  import {
12
+ AUTOMATION_SIM_IDLE_REAP_TTL_MS,
12
13
  DEFAULT_SOOTSIM_BRIDGE_PORT,
13
14
  SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
14
15
  SOOTSIM_BRIDGE_SIM_CLOSE_REASON,
@@ -40,6 +41,7 @@ import {
40
41
  resolveDevCheckoutRuntimeRoot,
41
42
  resolveRuntimeRootForHostname,
42
43
  runtimeVersionFromHostname,
44
+ sendFileWithRanges,
43
45
  } from '../runtime-assets.ts'
44
46
  import { rnxRuntime } from '../runtime-delivery.ts'
45
47
  import { AgentHost, type AgentHostOptions } from './agent-host.ts'
@@ -260,23 +262,6 @@ export interface BridgeHostOptions {
260
262
  * `peach serve` command resolves this with the same probe the
261
263
  * upload itself uses (`resolveDefaultUploadOrigin`). */
262
264
  contrastOrigin?: string
263
- /** override the abandoned-sim GC TTL (ms). defaults to
264
- * SIM_IDLE_REAP_TTL_MS (30 min). exists so the reaper integration test
265
- * can drive the real timer path deterministically instead of mocking
266
- * time. production callers should never set this. */
267
- simIdleReapTtlMs?: number
268
- /** override the idle GC TTL (ms) for automation sims (playwright-driver,
269
- * capture/factory). defaults to AUTOMATION_SIM_IDLE_REAP_TTL_MS (10 min).
270
- * test-only, like simIdleReapTtlMs. */
271
- automationSimIdleReapTtlMs?: number
272
- /** cap on concurrent non-primary automation sims before the reaper drops
273
- * the most-idle overflow. defaults to MAX_CONCURRENT_AUTOMATION_SIMS (6).
274
- * test-only. */
275
- maxConcurrentAutomationSims?: number
276
- /** grace (ms) below which an automation sim is never reaped by the cap,
277
- * even when over it. defaults to AUTOMATION_SIM_ACTIVE_GRACE_MS (60 s).
278
- * test-only. */
279
- automationSimActiveGraceMs?: number
280
265
  }
281
266
 
282
267
  const DAEMON_HEARTBEAT_INTERVAL_MS = 5_000
@@ -417,25 +402,9 @@ export class SootSimBridgeHost {
417
402
  // terminal 4001 code, so the client closes the abandoned window instead
418
403
  // of reconnecting the zombie straight back in.
419
404
  private static SIM_IDLE_REAP_TTL_MS = 30 * 60_000
420
- // automation sims (spawned by the playwright driver: `peach open --driver
421
- // playwright`, the factory/screenshot capture path — `meta.sootsimHostDriver
422
- // === 'playwright'`) are unattended one-shot browsers, never a human's tab. a
423
- // capture run opens one per slide/app/retry and the 30-min human/dev TTL lets
424
- // hundreds pile up inside the window — orphaned ms-playwright chromiums that
425
- // drove the box to swap-thrash (load hit 157 in one incident). give them a far
426
- // shorter idle TTL, and cap how many can sit concurrently: reap the most-idle
427
- // overflow, but never a sim active within ACTIVE_GRACE (an agent composing
428
- // between captures), nor primary / cli-attached / leased sims.
429
- private static AUTOMATION_SIM_IDLE_REAP_TTL_MS = 10 * 60_000
430
- private static MAX_CONCURRENT_AUTOMATION_SIMS = 6
431
- private static AUTOMATION_SIM_ACTIVE_GRACE_MS = 60_000
432
405
 
433
406
  private preferredPort: number
434
407
  private portFallbackCount: number
435
- private simIdleReapTtlMs: number
436
- private automationSimIdleReapTtlMs: number
437
- private maxConcurrentAutomationSims: number
438
- private automationSimActiveGraceMs: number
439
408
  private shouldWriteLockfile: boolean
440
409
  private shouldWriteDevLockfile: boolean
441
410
  private getShellPort: (() => number | null) | null
@@ -486,14 +455,6 @@ export class SootSimBridgeHost {
486
455
  getExcludePorts: opts.agentScanExcludes,
487
456
  })
488
457
  this.contrastOrigin = opts.contrastOrigin?.replace(/\/$/, '') || null
489
- this.simIdleReapTtlMs =
490
- opts.simIdleReapTtlMs ?? SootSimBridgeHost.SIM_IDLE_REAP_TTL_MS
491
- this.automationSimIdleReapTtlMs =
492
- opts.automationSimIdleReapTtlMs ?? SootSimBridgeHost.AUTOMATION_SIM_IDLE_REAP_TTL_MS
493
- this.maxConcurrentAutomationSims =
494
- opts.maxConcurrentAutomationSims ?? SootSimBridgeHost.MAX_CONCURRENT_AUTOMATION_SIMS
495
- this.automationSimActiveGraceMs =
496
- opts.automationSimActiveGraceMs ?? SootSimBridgeHost.AUTOMATION_SIM_ACTIVE_GRACE_MS
497
458
  }
498
459
 
499
460
  /** expose the agent host so tests and embedders can inspect state or
@@ -502,14 +463,6 @@ export class SootSimBridgeHost {
502
463
  return this.agentHost
503
464
  }
504
465
 
505
- /** run the abandoned-sim GC pass on demand. the reaper normally fires off
506
- * the 30s idle-sweep timer; this lets the F21-3 integration test drive
507
- * the real path without waiting for the timer. not part of the public
508
- * WS protocol. */
509
- reapIdleSimsForTest(now = Date.now()): void {
510
- this.reapIdleSims(now)
511
- }
512
-
513
466
  /** synchronous wrapper around startAsync for callers that don't care
514
467
  * about port fallback outcomes. returns immediately; actual binding
515
468
  * happens on the event loop. callers that need to know the bound port
@@ -816,6 +769,13 @@ export class SootSimBridgeHost {
816
769
  return
817
770
  }
818
771
 
772
+ // a capture the page is still running after its cli exited (`peach
773
+ // record start`) is use the idle reaper cannot see in cli traffic.
774
+ if (msg.type === 'bridge:capture-active' && sim) {
775
+ sim.lastActiveAt = Date.now()
776
+ return
777
+ }
778
+
819
779
  // write a partial patch into ~/.peach/config.json. fired by the
820
780
  // engine's settingsStore when a persisted key flips in a browser
821
781
  // tab (which has no fs access of its own). after the merge, we
@@ -1345,43 +1305,10 @@ export class SootSimBridgeHost {
1345
1305
 
1346
1306
  const contentType =
1347
1307
  HTTP_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
1348
- const total = stats.size
1349
- const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '')
1350
- if (range) {
1351
- const [, startRaw, endRaw] = range
1352
- const start = startRaw ? Number(startRaw) : 0
1353
- const end = endRaw ? Math.min(Number(endRaw), total - 1) : total - 1
1354
- if (!Number.isFinite(start) || start > end || start >= total) {
1355
- res.writeHead(416, { ...cors, 'Content-Range': `bytes */${total}` })
1356
- res.end()
1357
- return
1358
- }
1359
- res.writeHead(206, {
1360
- ...cors,
1361
- 'Content-Type': contentType,
1362
- 'Content-Range': `bytes ${start}-${end}/${total}`,
1363
- 'Accept-Ranges': 'bytes',
1364
- 'Content-Length': String(end - start + 1),
1365
- })
1366
- if (method === 'HEAD') {
1367
- res.end()
1368
- return
1369
- }
1370
- fs.createReadStream(filePath, { start, end }).pipe(res)
1371
- return
1372
- }
1373
-
1374
- res.writeHead(200, {
1308
+ sendFileWithRanges(req, res, filePath, stats.size, {
1375
1309
  ...cors,
1376
1310
  'Content-Type': contentType,
1377
- 'Accept-Ranges': 'bytes',
1378
- 'Content-Length': String(total),
1379
1311
  })
1380
- if (method === 'HEAD') {
1381
- res.end()
1382
- return
1383
- }
1384
- fs.createReadStream(filePath).pipe(res)
1385
1312
  }
1386
1313
 
1387
1314
  private handleServerScan(res: ServerResponse) {
@@ -2025,71 +1952,33 @@ export class SootSimBridgeHost {
2025
1952
  // automation sims are spawned by the playwright driver (`peach open
2026
1953
  // --driver playwright`, the factory/screenshot capture path), which tags them
2027
1954
  // with `meta.sootsimHostDriver === 'playwright'` via the host's addInitScript.
2028
- // they are unattended one-shot browsers, never a human's tab, so they get a
2029
- // shorter idle TTL and a concurrent cap.
2030
1955
  private isAutomationSim(sim: BridgeSimConnection): boolean {
2031
1956
  return sim.meta?.sootsimHostDriver === 'playwright'
2032
1957
  }
2033
1958
 
2034
- // an automation sim whose spawning playwright host process is still alive is
2035
- // an ACTIVE run, not an abandoned tab. mirrors the cli's playwrightHostPidForSim
2036
- // (control.ts): extract the host pid from meta, then check liveness so the
2037
- // host-side idle GC never kills a live capture/factory sim mid-run.
2038
- private automationSimOwnerAlive(sim: BridgeSimConnection): boolean {
2039
- const pid = Number(sim.meta?.sootsimHostPid)
2040
- if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false
2041
- try {
2042
- process.kill(pid, 0) // ESRCH → dead; EPERM → alive but unsignalable
2043
- return true
2044
- } catch (err) {
2045
- return (err as NodeJS.ErrnoException).code === 'EPERM'
2046
- }
2047
- }
2048
-
2049
1959
  private reapIdleSims(now = Date.now()) {
2050
1960
  const attachedSimIds = new Set(this.cliSimBySocket.values())
2051
- // automation sims that pass every "someone cares" gate but are still under
2052
- // their TTL — candidates for the concurrent-cap pass below.
2053
- const automationLive: { sim: BridgeSimConnection; idleMs: number }[] = []
2054
1961
  for (const sim of this.sims.values()) {
2055
- if (sim.id === this.primarySimId) continue
2056
1962
  if (attachedSimIds.has(sim.id)) continue
2057
- if (this.getActiveLease(sim)) continue
2058
- // never-CLI-driven sims have lastActiveAt 0 — fall back to connectedAt
2059
- // so a freshly opened, not-yet-primary tab isn't reaped on the spot.
2060
- const lastTouched = Math.max(sim.lastActiveAt, sim.connectedAt)
2061
- const idleMs = now - lastTouched
2062
1963
  const automation = this.isAutomationSim(sim)
2063
- // a live-owner automation sim is an ACTIVE run (its spawning playwright host
2064
- // process is still alive) — never reap it. the host keeps Chrome alive
2065
- // intentionally and tears it down itself when ITS owner driver dies; only
2066
- // orphaned/host-dead autos are the leak this reaper targets. (2026-06: the
2067
- // 10-min automation TTL was reaping live capture + factory sims mid-run —
2068
- // too aggressive.) continue skips both the TTL reap and the cap pass below.
2069
- if (automation && this.automationSimOwnerAlive(sim)) continue
2070
- const ttl = automation ? this.automationSimIdleReapTtlMs : this.simIdleReapTtlMs
2071
- if (idleMs >= ttl) {
2072
- // terminal close → client closes the window, does not reconnect; the
2073
- // 'close' handler runs rememberDisconnectedSim → this.sims.delete.
2074
- this.closeSimSocketFromHost(sim.ws)
1964
+ const lease = this.getActiveLease(sim)
1965
+ // an automation sim is nobody's tab, so being primary or holding a cli
1966
+ // lease (10 min from its last grant) says nothing about use: only a human
1967
+ // interacting with a headed window keeps one past its idle window.
1968
+ if (
1969
+ automation ? lease?.kind === 'user-active' : sim.id === this.primarySimId || lease
1970
+ ) {
2075
1971
  continue
2076
1972
  }
2077
- if (automation) automationLive.push({ sim, idleMs })
2078
- }
2079
- // concurrent cap: a burst of capture opens can pile up hundreds inside the
2080
- // TTL window. keep at most maxConcurrentAutomationSims of the freshest;
2081
- // reap the most-idle overflow, but never one active within the grace (an
2082
- // agent mid-capture between screenshots).
2083
- const cap = this.maxConcurrentAutomationSims
2084
- if (automationLive.length > cap) {
2085
- automationLive.sort((a, b) => b.idleMs - a.idleMs) // most-idle first
2086
- for (const { sim, idleMs } of automationLive.slice(
2087
- 0,
2088
- automationLive.length - cap,
2089
- )) {
2090
- if (idleMs < this.automationSimActiveGraceMs) continue
2091
- this.closeSimSocketFromHost(sim.ws)
2092
- }
1973
+ // never-CLI-driven sims have lastActiveAt 0 — fall back to connectedAt
1974
+ // so a freshly opened, not-yet-primary tab isn't reaped on the spot.
1975
+ const idleMs = now - Math.max(sim.lastActiveAt, sim.connectedAt)
1976
+ const ttl = automation
1977
+ ? AUTOMATION_SIM_IDLE_REAP_TTL_MS
1978
+ : SootSimBridgeHost.SIM_IDLE_REAP_TTL_MS
1979
+ // terminal close → client closes the window, does not reconnect; the
1980
+ // 'close' handler runs rememberDisconnectedSim → this.sims.delete.
1981
+ if (idleMs >= ttl) this.closeSimSocketFromHost(sim.ws)
2093
1982
  }
2094
1983
  }
2095
1984
 
package/src/menu.ts CHANGED
@@ -80,6 +80,7 @@ export interface SootSimMenuState {
80
80
  // can reach.
81
81
  developmentMode: boolean
82
82
  showBezels: boolean
83
+ showTouches: boolean
83
84
  showMenuBar: boolean
84
85
  showRail: boolean
85
86
  solidWindow: boolean
@@ -493,6 +494,12 @@ function windowMenu(state: SootSimMenuState): SootSimMenuDef {
493
494
  checked: state.showBezels,
494
495
  action: 'toggle-bezels',
495
496
  },
497
+ {
498
+ label: 'Show Touches',
499
+ type: 'checkbox',
500
+ checked: state.showTouches,
501
+ action: 'toggle-touches',
502
+ },
496
503
  {
497
504
  label: 'Show Menu Bar',
498
505
  type: 'checkbox',
@@ -275,25 +275,13 @@ export const NATIVE_SEAM_MANIFEST: NativeSeamEntry[] = [
275
275
  },
276
276
  {
277
277
  pkg: 'expo-image',
278
- // upstream splits the native surface across two files:
279
- // - src/ExpoImage.tsx → requireNativeViewManager('ExpoImage')
280
- // - src/ImageModule.ts → requireNativeModule('ExpoImage')
281
- // the view-component seam translates expo-image's prop shape
282
- // (contentFit, tintColor, source) onto sootsim's existing
283
- // <sootsim-image> host. no new canvas component is needed — sootsim's
284
- // image-loader + canvaskit-renderer already handle resizeMode,
285
- // tintColor, and onLoad/onError lifecycle.
286
- seamBasenames: ['ExpoImage'],
287
- target: seamPath('expo-image'),
288
- notes:
289
- 'expo-image native view — thin prop translator onto sootsim-image host (contentFit→resizeMode, tintColor passthrough); upstream Image.tsx + ImageBackground + useImage resolve from node_modules unchanged',
290
- },
291
- {
292
- pkg: 'expo-image',
278
+ // src/ImageModule.ts → requireNativeModule('ExpoImage'). the native view
279
+ // is src/ExpoImage.tsx's requireNativeViewManager('ExpoImage') call inside
280
+ // a real JS component, so it comes from expo-image-register instead.
293
281
  seamBasenames: ['ImageModule'],
294
282
  target: seamPath('expo-image-module'),
295
283
  notes:
296
- 'expo-image native module — prefetch/loadAsync route through sootsim image-loader; cache controls noop (browser owns HTTP cache); blurhash encode not yet implemented',
284
+ 'expo-image native module — prefetch/loadAsync/readFromCacheAsync route through the engine image loader, blurhash and thumbhash generation run the swift encoders, cache configuration has nothing to set in a browser',
297
285
  },
298
286
  {
299
287
  pkg: '@notifee/react-native',
@@ -7,6 +7,7 @@ import path from 'path'
7
7
  import { fileURLToPath } from 'url'
8
8
  import { isValidRuntimeVersion } from '@contrast/runtime-delivery'
9
9
  import { readActiveRuntime, runtimeDir } from './home-paths.ts'
10
+ import type { ServerResponse } from 'http'
10
11
 
11
12
  export const SOOTSIM_RUNTIME_MISSING_MESSAGE =
12
13
  '[peach] no engine runtime installed. run `peach runtime install`'
@@ -97,6 +98,10 @@ const MIME_TYPES: Record<string, string> = {
97
98
  '.woff2': 'font/woff2',
98
99
  '.mp3': 'audio/mpeg',
99
100
  '.wav': 'audio/wav',
101
+ '.mp4': 'video/mp4',
102
+ '.m4v': 'video/mp4',
103
+ '.mov': 'video/quicktime',
104
+ '.webm': 'video/webm',
100
105
  }
101
106
 
102
107
  const ROOT_RUNTIME_PATHS = [
@@ -111,7 +116,7 @@ const ROOT_RUNTIME_PATHS = [
111
116
  '/spike/',
112
117
  '/test-wallpaper.jpg',
113
118
  '/preview-sw.js',
114
- '/sootsim.svg',
119
+ '/peach.svg',
115
120
  '/bedrock-sqlite.wasm',
116
121
  '/bedrock-sqlite.js',
117
122
  '/sootsim/',
@@ -238,5 +243,57 @@ export function serveRuntimeFile(req: any, res: any, fullPath: string) {
238
243
  res.end()
239
244
  return
240
245
  }
241
- fs.createReadStream(fullPath).pipe(res)
246
+ sendFileWithRanges(req, res, fullPath, stat.size, {})
247
+ }
248
+
249
+ // a file body that honours a single `bytes=` range. a media element can only
250
+ // seek a resource whose server answers ranges, so any file a <video> or
251
+ // <audio> might load goes through here.
252
+ export function sendFileWithRanges(
253
+ req: { method?: string; headers?: { range?: string } } | undefined,
254
+ res: ServerResponse,
255
+ filePath: string,
256
+ total: number,
257
+ headers: Record<string, string>,
258
+ ) {
259
+ const head = (req?.method || 'GET').toUpperCase() === 'HEAD'
260
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req?.headers?.range || '')
261
+ // a last byte before the first makes the header invalid, which a server
262
+ // ignores (RFC 9110 14.1.1)
263
+ const invalid = range && range[1] && range[2] && Number(range[2]) < Number(range[1])
264
+ if (range && (range[1] || range[2]) && !invalid) {
265
+ const [, startRaw, endRaw] = range
266
+ // a suffix range (bytes=-N) is the last N bytes
267
+ const start = startRaw ? Number(startRaw) : Math.max(0, total - Number(endRaw))
268
+ const end = startRaw && endRaw ? Math.min(Number(endRaw), total - 1) : total - 1
269
+ if (start > end || start >= total) {
270
+ res.writeHead(416, { ...headers, 'Content-Range': `bytes */${total}` })
271
+ res.end()
272
+ return
273
+ }
274
+ res.writeHead(206, {
275
+ ...headers,
276
+ 'Content-Range': `bytes ${start}-${end}/${total}`,
277
+ 'Accept-Ranges': 'bytes',
278
+ 'Content-Length': String(end - start + 1),
279
+ })
280
+ if (head) res.end()
281
+ else streamFile(res, fs.createReadStream(filePath, { start, end }))
282
+ return
283
+ }
284
+ res.writeHead(200, {
285
+ ...headers,
286
+ 'Accept-Ranges': 'bytes',
287
+ 'Content-Length': String(total),
288
+ })
289
+ // the body stops at the stat'd size so a file a rebuild is still growing
290
+ // cannot run past its Content-Length
291
+ if (head || total === 0) res.end()
292
+ else streamFile(res, fs.createReadStream(filePath, { start: 0, end: total - 1 }))
293
+ }
294
+
295
+ // the status line is already out, so a read failure can only cut the body
296
+ function streamFile(res: ServerResponse, stream: fs.ReadStream) {
297
+ stream.on('error', (error) => res.destroy(error))
298
+ stream.pipe(res)
242
299
  }