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 +1 -1
- package/src/sdk/RoomOrchestrator.js +9 -120
- package/src/sdk/RoomOrchestratorHttp.js +139 -0
- package/src/sdk/StaticCache.js +281 -0
- package/src/sdk/StaticHandler.js +8 -274
package/package.json
CHANGED
|
@@ -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 {
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sdk/StaticHandler.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
12
|
-
|
|
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
|