spoint 0.1.651 → 0.1.653

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.651",
3
+ "version": "0.1.653",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -47,8 +47,11 @@
47
47
  import { fork } from 'node:child_process'
48
48
  import { fileURLToPath } from 'node:url'
49
49
  import { join, dirname } from 'node:path'
50
- import { createServer as createHttpServer, request as httpRequest } from 'node:http'
51
- import { request as httpsRequest } from 'node:https'
50
+ import { readJsonBody, httpJsonRequest, scoreWorkerRooms, startRoomOrchestratorRouter } from './RoomOrchestratorHttp.js'
51
+
52
+ // Re-exported from RoomOrchestratorHttp.js for backward compatibility -- bin/room-orchestrator-boot.js
53
+ // imports readJsonBody from this file's own path.
54
+ export { readJsonBody }
52
55
 
53
56
  const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
54
57
  const WORKER_ENTRY = join(SDK_ROOT, 'src', 'sdk', 'RoomProcessWorker.js')
@@ -305,17 +308,11 @@ export class RoomOrchestrator {
305
308
  const perWorkerRooms = await Promise.all(
306
309
  readyIdx.map(i => this._send(i, { type: 'GET_STATUS' }).then(r => r.rooms).catch(() => null))
307
310
  )
308
- const PLAYER_WEIGHT = 1.0, ENTITY_WEIGHT = 0.02, TICKMS_WEIGHT = 0.5, DILATION_PENALTY = 50
309
311
  let allOverThreshold = readyIdx.length > 0
310
312
  for (let k = 0; k < readyIdx.length; k++) {
311
- const i = readyIdx[k]
312
313
  const rooms = perWorkerRooms[k]
313
314
  if (!rooms || rooms.length === 0) { allOverThreshold = false; break }
314
- const score = rooms.reduce((sum, r) => sum
315
- + (r.players || 0) * PLAYER_WEIGHT
316
- + (r.entities || 0) * ENTITY_WEIGHT
317
- + (r.avgTickMs || 0) * TICKMS_WEIGHT
318
- + (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY, 0)
315
+ const score = scoreWorkerRooms(rooms)
319
316
  if (score < this._elasticScaleUpThreshold) { allOverThreshold = false; break }
320
317
  }
321
318
  if (allOverThreshold) {
@@ -490,7 +487,6 @@ export class RoomOrchestrator {
490
487
  * failed) so a freshly-spawned empty worker is never starved of placement by a transient status gap.
491
488
  */
492
489
  async _pickWeightedWorker() {
493
- const PLAYER_WEIGHT = 1.0, ENTITY_WEIGHT = 0.02, TICKMS_WEIGHT = 0.5, DILATION_PENALTY = 50
494
490
  const readyIdx = []
495
491
  for (let i = 0; i < this.workers.length; i++) if (this.workers[i]?.ready && !this._retiring.has(i)) readyIdx.push(i)
496
492
  if (readyIdx.length === 0) throw new Error('RoomOrchestrator: no ready worker available to host a new room')
@@ -503,13 +499,7 @@ export class RoomOrchestrator {
503
499
  const rooms = perWorkerRooms[k]
504
500
  // No usable status (fetch failed, or genuinely zero rooms -> zero weight anyway) -- treat as
505
501
  // pure room-count load so an empty/unreachable-status worker is never unfairly skipped.
506
- const score = rooms
507
- ? rooms.reduce((sum, r) => sum
508
- + (r.players || 0) * PLAYER_WEIGHT
509
- + (r.entities || 0) * ENTITY_WEIGHT
510
- + (r.avgTickMs || 0) * TICKMS_WEIGHT
511
- + (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY, 0)
512
- : this.workers[i].roomIds.size
502
+ const score = rooms ? scoreWorkerRooms(rooms) : this.workers[i].roomIds.size
513
503
  if (score < bestScore) { bestScore = score; best = i }
514
504
  }
515
505
  return best
@@ -571,81 +561,9 @@ export class RoomOrchestrator {
571
561
  }
572
562
 
573
563
  /** Starts a minimal HTTP router on `port`: GET /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index -> deregister an external worker. Not a traffic proxy -- see class doc comment. */
564
+ /** Starts a minimal HTTP router on `port`: GET /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index -> deregister an external worker. Not a traffic proxy -- see class doc comment. Delegates to RoomOrchestratorHttp.js's startRoomOrchestratorRouter, which only reaches this instance through its public methods. */
574
565
  startRouter(port) {
575
- this.httpServer = createHttpServer(async (req, res) => {
576
- try {
577
- const url = new URL(req.url, 'http://localhost')
578
-
579
- // POST /workers/register -- register an external worker (running on a different Machine)
580
- // Body: { host: "my-machine.fly.dev", portRange?: [19000, 19015] }
581
- if (req.method === 'POST' && url.pathname === '/workers/register') {
582
- const body = await readJsonBody(req)
583
- if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
584
- try {
585
- const result = await this.registerWorker({ host: body.host, portRange: body.portRange })
586
- res.writeHead(201, { 'Content-Type': 'application/json' })
587
- res.end(JSON.stringify(result))
588
- } catch (e) {
589
- res.writeHead(409, { 'Content-Type': 'application/json' })
590
- res.end(JSON.stringify({ error: e?.message || String(e) }))
591
- }
592
- return
593
- }
594
-
595
- // DELETE /workers/:index -- deregister an external worker
596
- if (req.method === 'DELETE') {
597
- const wm = url.pathname.match(/^\/workers\/(\d+)$/)
598
- if (wm) {
599
- const ok = await this.deregisterWorker(parseInt(wm[1], 10))
600
- res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
601
- res.end(JSON.stringify({ deregistered: ok }))
602
- return
603
- }
604
- }
605
-
606
- // GET /workers -- list all workers with their hosts
607
- if (url.pathname === '/workers') {
608
- const list = []
609
- for (let i = 0; i < this.workers.length; i++) {
610
- const w = this.workers[i]
611
- if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
612
- }
613
- res.writeHead(200, { 'Content-Type': 'application/json' })
614
- res.end(JSON.stringify(list))
615
- return
616
- }
617
-
618
- // GET /crash-stats -- crash/restart stats for monitoring
619
- if (url.pathname === '/crash-stats') {
620
- res.writeHead(200, { 'Content-Type': 'application/json' })
621
- res.end(JSON.stringify(this.getCrashStats()))
622
- return
623
- }
624
-
625
- if (url.pathname === '/status') {
626
- const rooms = await this.getStatus()
627
- res.writeHead(200, { 'Content-Type': 'application/json' })
628
- res.end(JSON.stringify({ workerCount: this.workers.length, rooms }))
629
- return
630
- }
631
- const m = url.pathname.match(/^\/route\/(.+)$/)
632
- if (m) {
633
- const loc = this.route(decodeURIComponent(m[1]))
634
- if (!loc) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'room not found' })); return }
635
- res.writeHead(200, { 'Content-Type': 'application/json' })
636
- res.end(JSON.stringify(loc))
637
- return
638
- }
639
- res.writeHead(404); res.end('not found')
640
- } catch (e) {
641
- res.writeHead(500, { 'Content-Type': 'application/json' })
642
- res.end(JSON.stringify({ error: e?.message || String(e) }))
643
- }
644
- })
645
- return new Promise((resolve, reject) => {
646
- this.httpServer.once('error', reject)
647
- this.httpServer.listen(port, () => resolve({ port: this.httpServer.address().port }))
648
- })
566
+ return startRoomOrchestratorRouter(this, port)
649
567
  }
650
568
 
651
569
  /** Stops every worker process (each drains its own rooms via RoomDirectory.stopAll first) and the router HTTP listener. */
@@ -657,32 +575,3 @@ export class RoomOrchestrator {
657
575
  }
658
576
  }
659
577
 
660
- /** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
661
- export function readJsonBody(req) {
662
- return new Promise((resolve) => {
663
- let buf = ''
664
- req.on('data', (chunk) => { buf += chunk })
665
- req.on('end', () => {
666
- try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) }
667
- })
668
- req.on('error', () => resolve(null))
669
- })
670
- }
671
-
672
- function httpJsonRequest(url, method, body) {
673
- return new Promise((resolve, reject) => {
674
- const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
675
- const data = body !== undefined ? JSON.stringify(body) : null
676
- const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
677
- const req = requestFn(url, { method, headers }, (res) => {
678
- let buf = ''
679
- res.on('data', (c) => { buf += c })
680
- res.on('end', () => {
681
- try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
682
- })
683
- })
684
- req.on('error', reject)
685
- if (data) req.write(data)
686
- req.end()
687
- })
688
- }
@@ -0,0 +1,139 @@
1
+ // Pure HTTP helpers for RoomOrchestrator.js: JSON request-body reading (for the router's own
2
+ // listener) and JSON-over-HTTP(S) request/response (for talking to an EXTERNAL worker's command
3
+ // port). No reference to RoomOrchestrator's own instance state -- split out as the one genuinely
4
+ // stateless piece of that file.
5
+
6
+ import { createServer as createHttpServer, request as httpRequest } from 'node:http'
7
+ import { request as httpsRequest } from 'node:https'
8
+
9
+ /** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
10
+ export function readJsonBody(req) {
11
+ return new Promise((resolve) => {
12
+ let buf = ''
13
+ req.on('data', (chunk) => { buf += chunk })
14
+ req.on('end', () => {
15
+ try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) }
16
+ })
17
+ req.on('error', () => resolve(null))
18
+ })
19
+ }
20
+
21
+ export function httpJsonRequest(url, method, body) {
22
+ return new Promise((resolve, reject) => {
23
+ const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
24
+ const data = body !== undefined ? JSON.stringify(body) : null
25
+ const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
26
+ const req = requestFn(url, { method, headers }, (res) => {
27
+ let buf = ''
28
+ res.on('data', (c) => { buf += c })
29
+ res.on('end', () => {
30
+ try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
31
+ })
32
+ })
33
+ req.on('error', reject)
34
+ if (data) req.write(data)
35
+ req.end()
36
+ })
37
+ }
38
+
39
+ // server-scale-room-orchestrator-load-aware-placement's weight formula (see RoomOrchestrator.js's
40
+ // _pickWeightedWorker header for the full rationale) -- pure given a room-status row, no orchestrator
41
+ // instance state, so it is shared verbatim between _pickWeightedWorker (placement) and _elasticCheck
42
+ // (scale-up trigger) rather than kept as two independently-maintained copies of the same weights.
43
+ export const PLACEMENT_WEIGHTS = { PLAYER_WEIGHT: 1.0, ENTITY_WEIGHT: 0.02, TICKMS_WEIGHT: 0.5, DILATION_PENALTY: 50 }
44
+
45
+ export function scoreRoom(r) {
46
+ const { PLAYER_WEIGHT, ENTITY_WEIGHT, TICKMS_WEIGHT, DILATION_PENALTY } = PLACEMENT_WEIGHTS
47
+ return (r.players || 0) * PLAYER_WEIGHT
48
+ + (r.entities || 0) * ENTITY_WEIGHT
49
+ + (r.avgTickMs || 0) * TICKMS_WEIGHT
50
+ + (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY
51
+ }
52
+
53
+ export function scoreWorkerRooms(rooms) {
54
+ return rooms.reduce((sum, r) => sum + scoreRoom(r), 0)
55
+ }
56
+
57
+ // Starts the minimal HTTP router listener on `port` for a RoomOrchestrator instance `orch`: GET
58
+ // /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full
59
+ // fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index ->
60
+ // deregister, GET /workers -> list, GET /crash-stats -> crash/restart stats. Not a traffic proxy --
61
+ // see RoomOrchestrator.js's class doc comment. Only reaches `orch` through its public methods
62
+ // (registerWorker/deregisterWorker/getCrashStats/getStatus/route) plus a read of orch.workers, so
63
+ // this is safely split from the class despite touching orchestrator state.
64
+ export function startRoomOrchestratorRouter(orch, port) {
65
+ orch.httpServer = createHttpServer(async (req, res) => {
66
+ try {
67
+ const url = new URL(req.url, 'http://localhost')
68
+
69
+ // POST /workers/register -- register an external worker (running on a different Machine)
70
+ // Body: { host: "my-machine.fly.dev", portRange?: [19000, 19015] }
71
+ if (req.method === 'POST' && url.pathname === '/workers/register') {
72
+ const body = await readJsonBody(req)
73
+ if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
74
+ try {
75
+ const result = await orch.registerWorker({ host: body.host, portRange: body.portRange })
76
+ res.writeHead(201, { 'Content-Type': 'application/json' })
77
+ res.end(JSON.stringify(result))
78
+ } catch (e) {
79
+ res.writeHead(409, { 'Content-Type': 'application/json' })
80
+ res.end(JSON.stringify({ error: e?.message || String(e) }))
81
+ }
82
+ return
83
+ }
84
+
85
+ // DELETE /workers/:index -- deregister an external worker
86
+ if (req.method === 'DELETE') {
87
+ const wm = url.pathname.match(/^\/workers\/(\d+)$/)
88
+ if (wm) {
89
+ const ok = await orch.deregisterWorker(parseInt(wm[1], 10))
90
+ res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
91
+ res.end(JSON.stringify({ deregistered: ok }))
92
+ return
93
+ }
94
+ }
95
+
96
+ // GET /workers -- list all workers with their hosts
97
+ if (url.pathname === '/workers') {
98
+ const list = []
99
+ for (let i = 0; i < orch.workers.length; i++) {
100
+ const w = orch.workers[i]
101
+ if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
102
+ }
103
+ res.writeHead(200, { 'Content-Type': 'application/json' })
104
+ res.end(JSON.stringify(list))
105
+ return
106
+ }
107
+
108
+ // GET /crash-stats -- crash/restart stats for monitoring
109
+ if (url.pathname === '/crash-stats') {
110
+ res.writeHead(200, { 'Content-Type': 'application/json' })
111
+ res.end(JSON.stringify(orch.getCrashStats()))
112
+ return
113
+ }
114
+
115
+ if (url.pathname === '/status') {
116
+ const rooms = await orch.getStatus()
117
+ res.writeHead(200, { 'Content-Type': 'application/json' })
118
+ res.end(JSON.stringify({ workerCount: orch.workers.length, rooms }))
119
+ return
120
+ }
121
+ const m = url.pathname.match(/^\/route\/(.+)$/)
122
+ if (m) {
123
+ const loc = orch.route(decodeURIComponent(m[1]))
124
+ if (!loc) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'room not found' })); return }
125
+ res.writeHead(200, { 'Content-Type': 'application/json' })
126
+ res.end(JSON.stringify(loc))
127
+ return
128
+ }
129
+ res.writeHead(404); res.end('not found')
130
+ } catch (e) {
131
+ res.writeHead(500, { 'Content-Type': 'application/json' })
132
+ res.end(JSON.stringify({ error: e?.message || String(e) }))
133
+ }
134
+ })
135
+ return new Promise((resolve, reject) => {
136
+ orch.httpServer.once('error', reject)
137
+ orch.httpServer.listen(port, () => resolve({ port: orch.httpServer.address().port }))
138
+ })
139
+ }
@@ -0,0 +1,281 @@
1
+ // Byte-budgeted LRU caching + gzip/brotli compression infrastructure for StaticHandler.js's static
2
+ // file server: raw file bytes, compressed variants, and transformed (GLB/VRM-optimized) variants.
3
+ // No HTTP request/response handling here -- pure caching/compression, split out for a smaller,
4
+ // single-responsibility file.
5
+
6
+ import { readFileSync, existsSync, statSync, writeFileSync, readdirSync } from 'node:fs'
7
+ import { join, extname, sep } from 'node:path'
8
+ import { gzipSync, brotliCompressSync, gzip, brotliCompress, constants as zlibConstants } from 'node:zlib'
9
+ import { promisify } from 'node:util'
10
+
11
+ // quality 5: q11 default is 100x+ slower for marginal gain; q5 still beats gzip -6 by ~14% (measured on anim-lib.glb)
12
+ const BROTLI_OPTS = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } }
13
+
14
+ const gzipAsync = promisify(gzip)
15
+ const brotliCompressAsync = promisify(brotliCompress)
16
+
17
+ // Below this size, the sync zlib call costs sub-millisecond -- not worth the promise/microtask
18
+ // overhead, and small-file callers (e.g. tests driving the handler with a bare mock `res` and
19
+ // reading `res` synchronously right after the call returns) rely on the response being written
20
+ // before the call returns. Above it (large JS bundles, GLB/VRM/wasm) sync compression can run
21
+ // long enough to visibly stall the 128Hz tick sharing this event loop, so it goes through the
22
+ // async zlib API instead.
23
+ const ASYNC_COMPRESS_THRESHOLD = 50 * 1024
24
+
25
+ export function compress(raw, encoding) {
26
+ return encoding === 'br' ? brotliCompressSync(raw, BROTLI_OPTS) : gzipSync(raw)
27
+ }
28
+
29
+ export async function compressAsync(raw, encoding) {
30
+ if (raw.length < ASYNC_COMPRESS_THRESHOLD) return compress(raw, encoding)
31
+ return encoding === 'br' ? brotliCompressAsync(raw, BROTLI_OPTS) : gzipAsync(raw)
32
+ }
33
+
34
+ // excludes already-compressed/high-entropy image formats; GLB/VRM/glTF still win since they carry uncompressed JSON+animation data
35
+ export const GZIP_EXTENSIONS = new Set(['.glb', '.vrm', '.gltf', '.js', '.mjs', '.css', '.html', '.json'])
36
+
37
+ // Raw bytes for anything bigger than this never enter the in-memory cache -- a single huge asset
38
+ // (large baked GLB, video, etc) would otherwise dominate the byte budget and evict everything else
39
+ // for one requester's benefit. Still served fine, just re-read from disk (OS page cache absorbs the
40
+ // repeat cost) instead of being pinned in process memory.
41
+ export const MAX_CACHEABLE_BYTES = 20 * 1024 * 1024
42
+
43
+ // Total byte budget across both LRU caches combined (raw file bytes + compressed variants +
44
+ // transformed/optimized GLB variants). Split proportionally isn't necessary -- one shared budget,
45
+ // evicted oldest-first, keeps the accounting simple and self-balancing between the two caches.
46
+ const CACHE_BYTE_BUDGET = 256 * 1024 * 1024
47
+
48
+ // Minimal Map-based LRU: `Map` iterates insertion order, so a re-set on touch (delete+set) moves an
49
+ // entry to the "most recently used" end for free, and eviction just shifts from the front.
50
+ export class ByteBudgetLRU {
51
+ constructor(budget) {
52
+ this.budget = budget
53
+ this.bytes = 0
54
+ this.map = new Map()
55
+ }
56
+ _sizeOf(entry) {
57
+ // entry.raw for fileCache rows, entry.variants Map values, entry.content for pass-through rows
58
+ let n = entry.raw ? entry.raw.length : 0
59
+ if (entry.variants) for (const v of entry.variants.values()) n += v.length
60
+ if (entry.content) n += entry.content.length
61
+ return n
62
+ }
63
+ get(key) {
64
+ const entry = this.map.get(key)
65
+ if (!entry) return undefined
66
+ // touch: move to MRU position
67
+ this.map.delete(key)
68
+ this.map.set(key, entry)
69
+ return entry
70
+ }
71
+ set(key, entry) {
72
+ const prior = this.map.get(key)
73
+ if (prior) this.bytes -= this._sizeOf(prior)
74
+ this.map.delete(key)
75
+ this.map.set(key, entry)
76
+ this.bytes += this._sizeOf(entry)
77
+ this._evictOverBudget()
78
+ }
79
+ // call after mutating an entry already in the map in-place (e.g. adding a new compressed variant)
80
+ // so the tracked byte total stays accurate without a full re-set/re-promote.
81
+ resync(key) {
82
+ if (!this.map.has(key)) return
83
+ let total = 0
84
+ for (const entry of this.map.values()) total += this._sizeOf(entry)
85
+ this.bytes = total
86
+ this._evictOverBudget()
87
+ }
88
+ delete(key) {
89
+ const entry = this.map.get(key)
90
+ if (entry) this.bytes -= this._sizeOf(entry)
91
+ this.map.delete(key)
92
+ }
93
+ _evictOverBudget() {
94
+ while (this.bytes > this.budget && this.map.size > 0) {
95
+ const oldestKey = this.map.keys().next().value
96
+ this.delete(oldestKey)
97
+ }
98
+ }
99
+ }
100
+
101
+ export const fileCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
102
+ export const transformedCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
103
+
104
+ // Content-hash ETag for /node_modules: third-party deps are re-materialized byte-identical on every
105
+ // redeploy (fresh `npm install`/checkout gives every file a NEW mtime even when its bytes didn't
106
+ // change), so an mtime-based ETag (the general path below) forces a needless revalidation round-trip
107
+ // on every redeploy. Hashing raw content instead means an unchanged file keeps the SAME ETag across
108
+ // redeploys, so a client's cached copy still 304s. Same fnv1a-1a used by SnapshotEncoder.js for
109
+ // dirty-detection -- non-cryptographic, fast, adequate for a weak validator (ETag is not a security
110
+ // boundary). Cached per (path, mtime) so a warm process only hashes each file once; a real content
111
+ // edit still gets a fresh mtime and recomputes.
112
+ const _contentHashCache = new Map() // fp -> { mtime, hash }
113
+ export function contentHashETag(fp, raw, mtime) {
114
+ const cached = _contentHashCache.get(fp)
115
+ if (cached && cached.mtime === mtime) return cached.hash
116
+ let hash = 2166136261
117
+ for (let i = 0; i < raw.length; i++) { hash ^= raw[i]; hash = Math.imul(hash, 16777619) }
118
+ const hex = (hash >>> 0).toString(16)
119
+ _contentHashCache.set(fp, { mtime, hash: hex })
120
+ return hex
121
+ }
122
+ export function isNodeModulesPath(fp) {
123
+ return fp.includes(sep + 'node_modules' + sep) || fp.endsWith(sep + 'node_modules')
124
+ }
125
+
126
+ const SIBLING_EXT = { br: '.br', gzip: '.gz' }
127
+
128
+ // Disk-persisted sibling (<file>.br / <file>.gz next to the source) so a compressed variant
129
+ // survives a process restart/redeploy instead of being recomputed from scratch every boot --
130
+ // this is the actual "precompress at bake time" behavior; the in-memory Map above is still the
131
+ // hot per-process cache layered on top so a warm process never touches disk twice for the same
132
+ // (file, encoding) pair. A stale sibling (source mtime moved on) is detected via a ".meta" JSON
133
+ // stamp recording the source mtime it was built from, same pattern as GLBTransformer's cache.
134
+ function siblingPaths(fp, encoding) {
135
+ const ext = SIBLING_EXT[encoding]
136
+ return { body: fp + ext, meta: fp + ext + '.meta' }
137
+ }
138
+
139
+ function readSiblingIfFresh(fp, encoding, srcMtime) {
140
+ const { body, meta } = siblingPaths(fp, encoding)
141
+ if (!existsSync(body) || !existsSync(meta)) return null
142
+ try {
143
+ const m = JSON.parse(readFileSync(meta, 'utf8'))
144
+ if (m.srcMtime !== srcMtime) return null
145
+ return readFileSync(body)
146
+ } catch { return null }
147
+ }
148
+
149
+ function writeSibling(fp, encoding, srcMtime, content) {
150
+ const { body, meta } = siblingPaths(fp, encoding)
151
+ try {
152
+ writeFileSync(body, content)
153
+ writeFileSync(meta, JSON.stringify({ srcMtime }))
154
+ } catch { /* read-only fs (e.g. some CDN/edge mounts) -- in-memory cache above still serves fine */ }
155
+ }
156
+
157
+ // lazily-populated compressed variants keyed by encoding, so each of a br- and non-br-capable client pays the compression cost once
158
+ export async function getCached(fp, ext, encoding) {
159
+ const key = fp
160
+ const mtime = statSync(fp).mtimeMs
161
+ let cached = fileCache.get(key)
162
+ const size = cached?.raw ? cached.raw.length : statSync(fp).size
163
+ const cacheable = size <= MAX_CACHEABLE_BYTES
164
+ if (!cached || cached.mtime !== mtime) {
165
+ const raw = readFileSync(fp)
166
+ cached = { mtime, raw, variants: new Map() }
167
+ if (raw.length <= MAX_CACHEABLE_BYTES) fileCache.set(key, cached)
168
+ else fileCache.delete(key)
169
+ }
170
+ const shouldCompress = encoding && GZIP_EXTENSIONS.has(ext) && cached.raw.length > 100
171
+ if (!shouldCompress) return { mtime: cached.mtime, content: cached.raw, encoding: null, raw: cached.raw }
172
+ let variant = cached.variants.get(encoding)
173
+ if (!variant) {
174
+ variant = readSiblingIfFresh(fp, encoding, cached.mtime)
175
+ if (!variant) {
176
+ variant = await compressAsync(cached.raw, encoding)
177
+ writeSibling(fp, encoding, cached.mtime, variant)
178
+ }
179
+ cached.variants.set(encoding, variant)
180
+ if (cacheable) fileCache.resync(key)
181
+ }
182
+ return { mtime: cached.mtime, content: variant, encoding, raw: cached.raw }
183
+ }
184
+
185
+ export async function getTransformedCached(fp, srcMtime, rawBuffer, encoding) {
186
+ let cached = transformedCache.get(fp)
187
+ if (!cached || cached.srcMtime !== srcMtime) {
188
+ cached = { srcMtime, variants: new Map(), raw: rawBuffer.length <= MAX_CACHEABLE_BYTES ? rawBuffer : null }
189
+ if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.set(fp, cached)
190
+ else transformedCache.delete(fp)
191
+ }
192
+ if (!encoding) return { srcMtime, content: rawBuffer, encoding: null }
193
+ let variant = cached.variants.get(encoding)
194
+ if (!variant) {
195
+ variant = await compressAsync(rawBuffer, encoding)
196
+ cached.variants.set(encoding, variant)
197
+ if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.resync(fp)
198
+ }
199
+ return { srcMtime, content: variant, encoding }
200
+ }
201
+
202
+ // Bake-time precompression: walk each mounted static dir and populate the .br/.gz disk siblings
203
+ // for every GZIP_EXTENSIONS file up front, so the very first request for any given asset already
204
+ // hits a warm sibling instead of paying brotli-q5 compression inline. Safe to call repeatedly
205
+ // (mtime-gated, same as the lazy path) -- intended to run once at server boot, backgrounded.
206
+ // A node_modules-rooted mount (third-party deps, can be 10⁴-10⁵ files) is deliberately excluded --
207
+ // walking + brotli-compressing the whole dependency tree at boot is unbounded work for code this
208
+ // app doesn't own; those files still compress fine on the lazy per-request path (getCached), just
209
+ // without the boot-time head start. Same for any nested node_modules encountered mid-walk.
210
+ const PREWARM_SKIP_DIRS = new Set(['node_modules', '.glb-cache', '.progressive-cache', '.git'])
211
+
212
+ export async function prewarmCompression(dirs) {
213
+ let count = 0
214
+ async function walk(dir) {
215
+ let entries
216
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
217
+ for (const e of entries) {
218
+ if (e.isDirectory() && PREWARM_SKIP_DIRS.has(e.name)) continue
219
+ const fp = join(dir, e.name)
220
+ if (e.isDirectory()) { await walk(fp); continue }
221
+ const ext = extname(e.name)
222
+ if (!GZIP_EXTENSIONS.has(ext)) continue
223
+ if (ext === '.br' || ext === '.gz') continue
224
+ try {
225
+ if (statSync(fp).size <= 100) continue
226
+ await getCached(fp, ext, 'br')
227
+ await getCached(fp, ext, 'gzip')
228
+ count++
229
+ } catch { /* unreadable file -- skip, request-time path still covers it */ }
230
+ }
231
+ }
232
+ for (const { dir, prefix } of dirs) {
233
+ if (prefix === '/node_modules/' || dir.endsWith(sep + 'node_modules') || dir.endsWith('/node_modules')) continue
234
+ await walk(dir)
235
+ }
236
+ return count
237
+ }
238
+
239
+ // Parses a single-range `Range: bytes=start-end` header (the only form browsers/download managers
240
+ // send for a resumed GLB/wasm fetch; multi-range is not worth supporting here). Returns null for
241
+ // anything absent/malformed/unsatisfiable so the caller falls back to a plain 200.
242
+ export function parseRange(rangeHeader, totalSize) {
243
+ if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
244
+ const spec = rangeHeader.slice(6).split(',')[0].trim()
245
+ const m = /^(\d*)-(\d*)$/.exec(spec)
246
+ if (!m) return null
247
+ let start, end
248
+ if (m[1] === '' && m[2] === '') return null
249
+ if (m[1] === '') {
250
+ // suffix range: last N bytes
251
+ const suffixLen = parseInt(m[2], 10)
252
+ if (!Number.isFinite(suffixLen) || suffixLen <= 0) return null
253
+ start = Math.max(0, totalSize - suffixLen)
254
+ end = totalSize - 1
255
+ } else {
256
+ start = parseInt(m[1], 10)
257
+ end = m[2] === '' ? totalSize - 1 : parseInt(m[2], 10)
258
+ }
259
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start < 0 || start >= totalSize) return null
260
+ end = Math.min(end, totalSize - 1)
261
+ return { start, end }
262
+ }
263
+
264
+ // Range/206 is only meaningful against the UNCOMPRESSED body -- a byte offset into a brotli/gzip
265
+ // stream is meaningless to the client, so a Range request always gets the identity encoding.
266
+ export function serveRangeable(req, res, buf, headers) {
267
+ headers['Accept-Ranges'] = 'bytes'
268
+ const range = parseRange(req.headers['range'], buf.length)
269
+ if (!range) {
270
+ headers['Content-Length'] = buf.length
271
+ res.writeHead(200, headers)
272
+ res.end(buf)
273
+ return
274
+ }
275
+ const { start, end } = range
276
+ headers['Content-Range'] = `bytes ${start}-${end}/${buf.length}`
277
+ headers['Content-Length'] = end - start + 1
278
+ delete headers['ETag'] // ETag above was computed for the whole-file 200 case; a 206 still names the same resource via Content-Range so omit rather than mismatch
279
+ res.writeHead(206, headers)
280
+ res.end(buf.subarray(start, end + 1))
281
+ }
@@ -1,26 +1,18 @@
1
- import { readFileSync, existsSync, statSync, realpathSync, writeFileSync, readdirSync } from 'node:fs'
1
+ import { existsSync, statSync, realpathSync } from 'node:fs'
2
2
  import { join, extname, resolve, sep } from 'node:path'
3
- import { gzipSync, brotliCompressSync, gzip, brotliCompress, constants as zlibConstants } from 'node:zlib'
4
- import { promisify } from 'node:util'
5
3
  import { getTransformedAsync, getTransformedHashAsync } from '../static/GLBTransformer.js'
6
4
  import { getProgressive, resolveBakedFile } from '../static/ProgressiveBake.js'
7
5
  import { getKtx2Extracted, resolveKtx2File } from '../static/KTX2Extract.js'
8
6
  import { buildFetchManifest } from '../static/FetchManifest.js'
9
7
  import { getServerIdentity } from '../sdk/ServerIdentity.js'
8
+ import {
9
+ GZIP_EXTENSIONS, contentHashETag, isNodeModulesPath, getCached, getTransformedCached,
10
+ prewarmCompression, serveRangeable
11
+ } from './StaticCache.js'
10
12
 
11
- // quality 5: q11 default is 100x+ slower for marginal gain; q5 still beats gzip -6 by ~14% (measured on anim-lib.glb)
12
- const BROTLI_OPTS = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } }
13
-
14
- const gzipAsync = promisify(gzip)
15
- const brotliCompressAsync = promisify(brotliCompress)
16
-
17
- // Below this size, the sync zlib call costs sub-millisecond -- not worth the promise/microtask
18
- // overhead, and small-file callers (e.g. tests driving the handler with a bare mock `res` and
19
- // reading `res` synchronously right after the call returns) rely on the response being written
20
- // before the call returns. Above it (large JS bundles, GLB/VRM/wasm) sync compression can run
21
- // long enough to visibly stall the 128Hz tick sharing this event loop, so it goes through the
22
- // async zlib API instead.
23
- const ASYNC_COMPRESS_THRESHOLD = 50 * 1024
13
+ // Re-exported from StaticCache.js for backward compatibility -- server.js/ServerBoot.js imports
14
+ // prewarmCompression from this file's own path.
15
+ export { prewarmCompression }
24
16
 
25
17
  function negotiateEncoding(req) {
26
18
  const ae = req.headers['accept-encoding'] || ''
@@ -29,15 +21,6 @@ function negotiateEncoding(req) {
29
21
  return null
30
22
  }
31
23
 
32
- function compress(raw, encoding) {
33
- return encoding === 'br' ? brotliCompressSync(raw, BROTLI_OPTS) : gzipSync(raw)
34
- }
35
-
36
- async function compressAsync(raw, encoding) {
37
- if (raw.length < ASYNC_COMPRESS_THRESHOLD) return compress(raw, encoding)
38
- return encoding === 'br' ? brotliCompressAsync(raw, BROTLI_OPTS) : gzipAsync(raw)
39
- }
40
-
41
24
  const MIME_TYPES = {
42
25
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
43
26
  '.json': 'application/json', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.vrm': 'model/gltf-binary',
@@ -57,9 +40,6 @@ const MIME_TYPES = {
57
40
  // of GLBTransformer's separate transformed-bytes hash cache.
58
41
  const CONTENT_HASHED_EXTENSIONS = new Set(['.hf'])
59
42
 
60
- // excludes already-compressed/high-entropy image formats; GLB/VRM/glTF still win since they carry uncompressed JSON+animation data
61
- const GZIP_EXTENSIONS = new Set(['.glb', '.vrm', '.gltf', '.js', '.mjs', '.css', '.html', '.json'])
62
-
63
43
  // Extensions worth serving Range/206 for -- large downloadable binaries where a dropped connection
64
44
  // resuming from a byte offset beats re-downloading from zero. Text/JS assets are small and revalidated
65
45
  // per-request anyway, so Range support for them buys nothing and adds surface area. .ktx2 added for the
@@ -79,252 +59,6 @@ const RANGE_EXTENSIONS = new Set(['.glb', '.vrm', '.gltf', '.wasm', '.ktx2'])
79
59
  // would itself become a real bytes-on-the-wire cost paid before any hint work even starts.
80
60
  const EARLY_HINTS_MAX = 12
81
61
 
82
- // Raw bytes for anything bigger than this never enter the in-memory cache -- a single huge asset
83
- // (large baked GLB, video, etc) would otherwise dominate the byte budget and evict everything else
84
- // for one requester's benefit. Still served fine, just re-read from disk (OS page cache absorbs the
85
- // repeat cost) instead of being pinned in process memory.
86
- const MAX_CACHEABLE_BYTES = 20 * 1024 * 1024
87
-
88
- // Total byte budget across both LRU caches combined (raw file bytes + compressed variants +
89
- // transformed/optimized GLB variants). Split proportionally isn't necessary -- one shared budget,
90
- // evicted oldest-first, keeps the accounting simple and self-balancing between the two caches.
91
- const CACHE_BYTE_BUDGET = 256 * 1024 * 1024
92
-
93
- // Minimal Map-based LRU: `Map` iterates insertion order, so a re-set on touch (delete+set) moves an
94
- // entry to the "most recently used" end for free, and eviction just shifts from the front.
95
- class ByteBudgetLRU {
96
- constructor(budget) {
97
- this.budget = budget
98
- this.bytes = 0
99
- this.map = new Map()
100
- }
101
- _sizeOf(entry) {
102
- // entry.raw for fileCache rows, entry.variants Map values, entry.content for pass-through rows
103
- let n = entry.raw ? entry.raw.length : 0
104
- if (entry.variants) for (const v of entry.variants.values()) n += v.length
105
- if (entry.content) n += entry.content.length
106
- return n
107
- }
108
- get(key) {
109
- const entry = this.map.get(key)
110
- if (!entry) return undefined
111
- // touch: move to MRU position
112
- this.map.delete(key)
113
- this.map.set(key, entry)
114
- return entry
115
- }
116
- set(key, entry) {
117
- const prior = this.map.get(key)
118
- if (prior) this.bytes -= this._sizeOf(prior)
119
- this.map.delete(key)
120
- this.map.set(key, entry)
121
- this.bytes += this._sizeOf(entry)
122
- this._evictOverBudget()
123
- }
124
- // call after mutating an entry already in the map in-place (e.g. adding a new compressed variant)
125
- // so the tracked byte total stays accurate without a full re-set/re-promote.
126
- resync(key) {
127
- if (!this.map.has(key)) return
128
- let total = 0
129
- for (const entry of this.map.values()) total += this._sizeOf(entry)
130
- this.bytes = total
131
- this._evictOverBudget()
132
- }
133
- delete(key) {
134
- const entry = this.map.get(key)
135
- if (entry) this.bytes -= this._sizeOf(entry)
136
- this.map.delete(key)
137
- }
138
- _evictOverBudget() {
139
- while (this.bytes > this.budget && this.map.size > 0) {
140
- const oldestKey = this.map.keys().next().value
141
- this.delete(oldestKey)
142
- }
143
- }
144
- }
145
-
146
- const fileCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
147
- const transformedCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
148
-
149
- // Content-hash ETag for /node_modules: third-party deps are re-materialized byte-identical on every
150
- // redeploy (fresh `npm install`/checkout gives every file a NEW mtime even when its bytes didn't
151
- // change), so an mtime-based ETag (the general path below) forces a needless revalidation round-trip
152
- // on every redeploy. Hashing raw content instead means an unchanged file keeps the SAME ETag across
153
- // redeploys, so a client's cached copy still 304s. Same fnv1a-1a used by SnapshotEncoder.js for
154
- // dirty-detection -- non-cryptographic, fast, adequate for a weak validator (ETag is not a security
155
- // boundary). Cached per (path, mtime) so a warm process only hashes each file once; a real content
156
- // edit still gets a fresh mtime and recomputes.
157
- const _contentHashCache = new Map() // fp -> { mtime, hash }
158
- function contentHashETag(fp, raw, mtime) {
159
- const cached = _contentHashCache.get(fp)
160
- if (cached && cached.mtime === mtime) return cached.hash
161
- let hash = 2166136261
162
- for (let i = 0; i < raw.length; i++) { hash ^= raw[i]; hash = Math.imul(hash, 16777619) }
163
- const hex = (hash >>> 0).toString(16)
164
- _contentHashCache.set(fp, { mtime, hash: hex })
165
- return hex
166
- }
167
- function isNodeModulesPath(fp) {
168
- return fp.includes(sep + 'node_modules' + sep) || fp.endsWith(sep + 'node_modules')
169
- }
170
-
171
- const SIBLING_EXT = { br: '.br', gzip: '.gz' }
172
-
173
- // Disk-persisted sibling (<file>.br / <file>.gz next to the source) so a compressed variant
174
- // survives a process restart/redeploy instead of being recomputed from scratch every boot --
175
- // this is the actual "precompress at bake time" behavior; the in-memory Map above is still the
176
- // hot per-process cache layered on top so a warm process never touches disk twice for the same
177
- // (file, encoding) pair. A stale sibling (source mtime moved on) is detected via a ".meta" JSON
178
- // stamp recording the source mtime it was built from, same pattern as GLBTransformer's cache.
179
- function siblingPaths(fp, encoding) {
180
- const ext = SIBLING_EXT[encoding]
181
- return { body: fp + ext, meta: fp + ext + '.meta' }
182
- }
183
-
184
- function readSiblingIfFresh(fp, encoding, srcMtime) {
185
- const { body, meta } = siblingPaths(fp, encoding)
186
- if (!existsSync(body) || !existsSync(meta)) return null
187
- try {
188
- const m = JSON.parse(readFileSync(meta, 'utf8'))
189
- if (m.srcMtime !== srcMtime) return null
190
- return readFileSync(body)
191
- } catch { return null }
192
- }
193
-
194
- function writeSibling(fp, encoding, srcMtime, content) {
195
- const { body, meta } = siblingPaths(fp, encoding)
196
- try {
197
- writeFileSync(body, content)
198
- writeFileSync(meta, JSON.stringify({ srcMtime }))
199
- } catch { /* read-only fs (e.g. some CDN/edge mounts) -- in-memory cache above still serves fine */ }
200
- }
201
-
202
- // lazily-populated compressed variants keyed by encoding, so each of a br- and non-br-capable client pays the compression cost once
203
- async function getCached(fp, ext, encoding) {
204
- const key = fp
205
- const mtime = statSync(fp).mtimeMs
206
- let cached = fileCache.get(key)
207
- const size = cached?.raw ? cached.raw.length : statSync(fp).size
208
- const cacheable = size <= MAX_CACHEABLE_BYTES
209
- if (!cached || cached.mtime !== mtime) {
210
- const raw = readFileSync(fp)
211
- cached = { mtime, raw, variants: new Map() }
212
- if (raw.length <= MAX_CACHEABLE_BYTES) fileCache.set(key, cached)
213
- else fileCache.delete(key)
214
- }
215
- const shouldCompress = encoding && GZIP_EXTENSIONS.has(ext) && cached.raw.length > 100
216
- if (!shouldCompress) return { mtime: cached.mtime, content: cached.raw, encoding: null, raw: cached.raw }
217
- let variant = cached.variants.get(encoding)
218
- if (!variant) {
219
- variant = readSiblingIfFresh(fp, encoding, cached.mtime)
220
- if (!variant) {
221
- variant = await compressAsync(cached.raw, encoding)
222
- writeSibling(fp, encoding, cached.mtime, variant)
223
- }
224
- cached.variants.set(encoding, variant)
225
- if (cacheable) fileCache.resync(key)
226
- }
227
- return { mtime: cached.mtime, content: variant, encoding, raw: cached.raw }
228
- }
229
-
230
- async function getTransformedCached(fp, srcMtime, rawBuffer, encoding) {
231
- let cached = transformedCache.get(fp)
232
- if (!cached || cached.srcMtime !== srcMtime) {
233
- cached = { srcMtime, variants: new Map(), raw: rawBuffer.length <= MAX_CACHEABLE_BYTES ? rawBuffer : null }
234
- if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.set(fp, cached)
235
- else transformedCache.delete(fp)
236
- }
237
- if (!encoding) return { srcMtime, content: rawBuffer, encoding: null }
238
- let variant = cached.variants.get(encoding)
239
- if (!variant) {
240
- variant = await compressAsync(rawBuffer, encoding)
241
- cached.variants.set(encoding, variant)
242
- if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.resync(fp)
243
- }
244
- return { srcMtime, content: variant, encoding }
245
- }
246
-
247
- // Bake-time precompression: walk each mounted static dir and populate the .br/.gz disk siblings
248
- // for every GZIP_EXTENSIONS file up front, so the very first request for any given asset already
249
- // hits a warm sibling instead of paying brotli-q5 compression inline. Safe to call repeatedly
250
- // (mtime-gated, same as the lazy path) -- intended to run once at server boot, backgrounded.
251
- // A node_modules-rooted mount (third-party deps, can be 10⁴-10⁵ files) is deliberately excluded --
252
- // walking + brotli-compressing the whole dependency tree at boot is unbounded work for code this
253
- // app doesn't own; those files still compress fine on the lazy per-request path (getCached), just
254
- // without the boot-time head start. Same for any nested node_modules encountered mid-walk.
255
- const PREWARM_SKIP_DIRS = new Set(['node_modules', '.glb-cache', '.progressive-cache', '.git'])
256
-
257
- export async function prewarmCompression(dirs) {
258
- let count = 0
259
- async function walk(dir) {
260
- let entries
261
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
262
- for (const e of entries) {
263
- if (e.isDirectory() && PREWARM_SKIP_DIRS.has(e.name)) continue
264
- const fp = join(dir, e.name)
265
- if (e.isDirectory()) { await walk(fp); continue }
266
- const ext = extname(e.name)
267
- if (!GZIP_EXTENSIONS.has(ext)) continue
268
- if (ext === '.br' || ext === '.gz') continue
269
- try {
270
- if (statSync(fp).size <= 100) continue
271
- await getCached(fp, ext, 'br')
272
- await getCached(fp, ext, 'gzip')
273
- count++
274
- } catch { /* unreadable file -- skip, request-time path still covers it */ }
275
- }
276
- }
277
- for (const { dir, prefix } of dirs) {
278
- if (prefix === '/node_modules/' || dir.endsWith(sep + 'node_modules') || dir.endsWith('/node_modules')) continue
279
- await walk(dir)
280
- }
281
- return count
282
- }
283
-
284
- // Parses a single-range `Range: bytes=start-end` header (the only form browsers/download managers
285
- // send for a resumed GLB/wasm fetch; multi-range is not worth supporting here). Returns null for
286
- // anything absent/malformed/unsatisfiable so the caller falls back to a plain 200.
287
- function parseRange(rangeHeader, totalSize) {
288
- if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
289
- const spec = rangeHeader.slice(6).split(',')[0].trim()
290
- const m = /^(\d*)-(\d*)$/.exec(spec)
291
- if (!m) return null
292
- let start, end
293
- if (m[1] === '' && m[2] === '') return null
294
- if (m[1] === '') {
295
- // suffix range: last N bytes
296
- const suffixLen = parseInt(m[2], 10)
297
- if (!Number.isFinite(suffixLen) || suffixLen <= 0) return null
298
- start = Math.max(0, totalSize - suffixLen)
299
- end = totalSize - 1
300
- } else {
301
- start = parseInt(m[1], 10)
302
- end = m[2] === '' ? totalSize - 1 : parseInt(m[2], 10)
303
- }
304
- if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start < 0 || start >= totalSize) return null
305
- end = Math.min(end, totalSize - 1)
306
- return { start, end }
307
- }
308
-
309
- // Range/206 is only meaningful against the UNCOMPRESSED body -- a byte offset into a brotli/gzip
310
- // stream is meaningless to the client, so a Range request always gets the identity encoding.
311
- function serveRangeable(req, res, buf, headers) {
312
- headers['Accept-Ranges'] = 'bytes'
313
- const range = parseRange(req.headers['range'], buf.length)
314
- if (!range) {
315
- headers['Content-Length'] = buf.length
316
- res.writeHead(200, headers)
317
- res.end(buf)
318
- return
319
- }
320
- const { start, end } = range
321
- headers['Content-Range'] = `bytes ${start}-${end}/${buf.length}`
322
- headers['Content-Length'] = end - start + 1
323
- delete headers['ETag'] // ETag above was computed for the whole-file 200 case; a 206 still names the same resource via Content-Range so omit rather than mismatch
324
- res.writeHead(206, headers)
325
- res.end(buf.subarray(start, end + 1))
326
- }
327
-
328
62
  // buildEarlyHintsLinks(manifest) -> ARRAY of individual Link header value strings (one per hinted
329
63
  // entry: `<url>; rel=preload; as=X`), or null if there is nothing to hint (no worldDef, empty
330
64
  // manifest). Node's real res.writeEarlyHints({link}) contract requires `link` to be a string OR an