sailkick-boat 0.13.4

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.
@@ -0,0 +1,247 @@
1
+ 'use strict'
2
+
3
+ // History module — serves the sailkick app's GET /api/history/series and
4
+ // /api/history/track from the boat's LOCAL InfluxDB (a signalk-to-influxdb-v2
5
+ // style bucket), so the app's Trends panel + track work offline with the full
6
+ // local dataset.
7
+ //
8
+ // This is the boat-edge counterpart of the app's server/history/influx-provider.js:
9
+ // the Flux queries and channel map are ported VERBATIM (the signalk-to-influxdb-v2 schema —
10
+ // measurement=SignalK path, field="value", position→lat/lon — is exactly what that
11
+ // provider assumes), and the JSON envelope is byte-for-byte the same the app's
12
+ // server/routes/history.js returns, so the browser client can't tell the difference.
13
+ // Keep MAP / the queries in sync with the app copy.
14
+ //
15
+ // When no local InfluxDB is configured (e.g. SignalK on a Victron GX / Venus OS)
16
+ // but a telemetry source is available, it instead serves a DB-less in-memory ring
17
+ // (lib/history/ring.js) — same JSON contract, ~1h offline window, no database.
18
+
19
+ const path = require('path')
20
+ const { RingHistoryProvider } = require('./ring')
21
+
22
+ const MS_TO_KT = 1.94384
23
+ const RAD2DEG = 180 / Math.PI
24
+ const wrap360 = (d) => ((d % 360) + 360) % 360
25
+ const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x }
26
+
27
+ // SignalK path (Influx measurement) → { chan, conv }. Field key is "value".
28
+ // Some channels have a fallback measurement (primary wins when both present).
29
+ const MAP = {
30
+ 'environment.wind.speedTrue': { chan: 'tws', conv: (v) => v * MS_TO_KT },
31
+ 'environment.wind.directionTrue': { chan: 'twd', conv: (v) => wrap360(v * RAD2DEG) },
32
+ 'environment.wind.speedApparent': { chan: 'aws', conv: (v) => v * MS_TO_KT },
33
+ 'environment.wind.angleApparent': { chan: 'awa', conv: (v) => wrap180(v * RAD2DEG) },
34
+ 'navigation.speedOverGround': { chan: 'sog', conv: (v) => v * MS_TO_KT },
35
+ 'navigation.speedThroughWater': { chan: 'stw', conv: (v) => v * MS_TO_KT },
36
+ 'navigation.headingTrue': { chan: 'heading', conv: (v) => wrap360(v * RAD2DEG) },
37
+ 'navigation.headingMagnetic': { chan: 'heading', conv: (v) => wrap360(v * RAD2DEG), fallback: true },
38
+ 'environment.depth.belowTransducer': { chan: 'depth', conv: (v) => v },
39
+ 'environment.depth.belowSurface': { chan: 'depth', conv: (v) => v, fallback: true }
40
+ }
41
+ const MEASUREMENTS = Object.keys(MAP)
42
+
43
+ // Parse a duration like "1h" / "30m" / "600s" / "3600" → seconds, clamped.
44
+ // Ported from the app's server/routes/history.js so limits match exactly.
45
+ function dur (s, def, min, max) {
46
+ if (s == null) return def
47
+ const m = String(s).trim().match(/^(\d+)\s*([smh]?)$/)
48
+ if (!m) return def
49
+ const n = parseInt(m[1], 10) * ({ s: 1, m: 60, h: 3600, '': 1 }[m[2]])
50
+ return Math.max(min, Math.min(max, n))
51
+ }
52
+
53
+ // Minimal InfluxDB v2 annotated-CSV parser (same approach as the app's
54
+ // server/influx/client.js). Values in our long-format queries are simple.
55
+ function parseAnnotatedCsv (text) {
56
+ const rows = []
57
+ let header = null
58
+ for (const raw of text.split('\n')) {
59
+ const line = raw.replace(/\r$/, '')
60
+ if (!line || line.startsWith('#')) { header = null; continue }
61
+ const cols = line.split(',')
62
+ if (!header) { header = cols; continue }
63
+ const o = {}
64
+ for (let i = 0; i < header.length; i++) o[header[i]] = cols[i]
65
+ rows.push(o)
66
+ }
67
+ return rows
68
+ }
69
+
70
+ function createHistory (app, options) {
71
+ const log = (m) => (app.debug ? app.debug('[history] ' + m) : console.log('[sailkick-boat:history]', m))
72
+ let cfg = null
73
+ let provider = null // { getSeries, getTrack, destroy? } — InfluxDB queries or the ring
74
+ let mode = null // 'influx' | 'ring' | null
75
+
76
+ function start () {
77
+ cfg = {
78
+ url: (options.influxUrl || 'http://127.0.0.1:8086').replace(/\/+$/, ''),
79
+ org: options.org || 'signalk',
80
+ bucket: options.bucket || 'signalk',
81
+ token: options.token || '',
82
+ timeoutMs: options.requestTimeoutMs || 15000
83
+ }
84
+ if (cfg.url && cfg.token && cfg.bucket) {
85
+ // Full history from a local InfluxDB.
86
+ provider = { getSeries: influxSeries, getTrack: influxTrack }
87
+ mode = 'influx'
88
+ log(`serving /api/history from ${cfg.url} bucket "${cfg.bucket}"`)
89
+ } else if (options.ringSource && options.ringSource.getState) {
90
+ // No local InfluxDB → DB-less rolling ring from live telemetry (GX/Venus OS).
91
+ // Persist the append-log so it survives restarts (unless ringPersist is off).
92
+ // Default location: a "history" folder under the tile cache dir (storeDir), so it
93
+ // sits on the SSD/USB with the tiles; `ringDir` overrides. Resolve storeDir the
94
+ // same way the proxy does.
95
+ const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
96
+ const storeDir = options.storeDir || path.join(dataDir, 'store')
97
+ const ringDir = options.ringDir || path.join(storeDir, 'history')
98
+ const persistFile = options.ringPersist !== false ? path.join(ringDir, 'history-ring.jsonl') : null
99
+ provider = new RingHistoryProvider({
100
+ source: options.ringSource,
101
+ windowSec: options.ringWindowSec,
102
+ sampleSec: options.ringSampleSec,
103
+ persistFile
104
+ })
105
+ mode = 'ring'
106
+ log(persistFile
107
+ ? `serving /api/history from a persistent DB-less ring (${persistFile})`
108
+ : 'serving /api/history from an in-memory DB-less ring')
109
+ } else {
110
+ provider = null; mode = null
111
+ log('no local InfluxDB and no telemetry — /api/history falls through to the mirror')
112
+ }
113
+ }
114
+
115
+ function stop () {
116
+ try { if (provider && provider.destroy) provider.destroy() } catch {}
117
+ provider = null; mode = null; cfg = null
118
+ }
119
+
120
+ // available() gates local serving: when false the proxy lets /api/history fall
121
+ // through to the cloud mirror (so we never make history worse than today).
122
+ function available () { return !!provider }
123
+
124
+ function status () {
125
+ if (!provider) return 'history: off'
126
+ return mode === 'ring' ? `history: ring${provider._persistFile ? ' (persistent)' : ''}` : `history: ${cfg.bucket}@local`
127
+ }
128
+
129
+ async function queryFlux (flux, signal) {
130
+ const url = `${cfg.url}/api/v2/query?org=${encodeURIComponent(cfg.org)}`
131
+ let resp
132
+ try {
133
+ resp = await fetch(url, {
134
+ method: 'POST',
135
+ headers: { Authorization: `Token ${cfg.token}`, 'Content-Type': 'application/vnd.flux', Accept: 'application/csv' },
136
+ body: flux,
137
+ signal: signal || AbortSignal.timeout(cfg.timeoutMs)
138
+ })
139
+ } catch (e) {
140
+ if (e && e.name === 'AbortError') return { ok: false, status: 0, message: 'InfluxDB query aborted/timed out' }
141
+ return { ok: false, status: 502, message: `Network error talking to InfluxDB: ${e.message}` }
142
+ }
143
+ if (!resp.ok) {
144
+ const body = await resp.text().catch(() => '')
145
+ return { ok: false, status: resp.status, message: `InfluxDB ${resp.status}: ${body.slice(0, 200)}` }
146
+ }
147
+ return { ok: true, rows: parseAnnotatedCsv(await resp.text()) }
148
+ }
149
+
150
+ async function influxSeries ({ windowSec, everySec, signal }) {
151
+ const filt = MEASUREMENTS.map((m) => `r._measurement == "${m}"`).join(' or ')
152
+ const flux = `from(bucket: "${cfg.bucket}")
153
+ |> range(start: -${windowSec}s)
154
+ |> filter(fn: (r) => r._field == "value" and (${filt}))
155
+ |> aggregateWindow(every: ${everySec}s, fn: mean, createEmpty: false)
156
+ |> keep(columns: ["_time", "_value", "_measurement"])`
157
+ const r = await queryFlux(flux, signal)
158
+ if (!r.ok) return r
159
+
160
+ const byChan = {} // chan -> { primary:[[t,v]], fallback:[[t,v]] }
161
+ for (const row of r.rows) {
162
+ const spec = MAP[row._measurement]
163
+ if (!spec) continue
164
+ const t = Date.parse(row._time)
165
+ const v = Number(row._value)
166
+ if (!Number.isFinite(t) || !Number.isFinite(v)) continue
167
+ const slot = (byChan[spec.chan] || (byChan[spec.chan] = { primary: [], fallback: [] }))
168
+ ;(spec.fallback ? slot.fallback : slot.primary).push([t, spec.conv(v)])
169
+ }
170
+ const series = {}
171
+ for (const [chan, { primary, fallback }] of Object.entries(byChan)) {
172
+ const pts = (primary.length ? primary : fallback).sort((a, b) => a[0] - b[0])
173
+ if (pts.length) series[chan] = pts
174
+ }
175
+ return { ok: true, series }
176
+ }
177
+
178
+ async function influxTrack ({ windowSec, everySec = 30, signal }) {
179
+ const flux = `from(bucket: "${cfg.bucket}")
180
+ |> range(start: -${windowSec}s)
181
+ |> filter(fn: (r) => r._measurement == "navigation.position" and (r._field == "lat" or r._field == "lon"))
182
+ |> aggregateWindow(every: ${everySec}s, fn: last, createEmpty: false)
183
+ |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
184
+ |> keep(columns: ["_time", "lat", "lon"])`
185
+ const r = await queryFlux(flux, signal)
186
+ if (!r.ok) return r
187
+ const track = []
188
+ for (const row of r.rows) {
189
+ const t = Date.parse(row._time)
190
+ const lat = Number(row.lat)
191
+ const lon = Number(row.lon)
192
+ if (Number.isFinite(t) && Number.isFinite(lat) && Number.isFinite(lon)) track.push({ t, lat, lon })
193
+ }
194
+ track.sort((a, b) => a.t - b.t)
195
+ return { ok: true, track }
196
+ }
197
+
198
+ // Abort the Influx query if the client disconnects mid-request.
199
+ function abortOnClose (res) {
200
+ const ac = new AbortController()
201
+ res.on('close', () => { if (!res.writableFinished) ac.abort() })
202
+ return ac.signal
203
+ }
204
+
205
+ function query (reqUrl) {
206
+ const qi = reqUrl.indexOf('?')
207
+ return new URLSearchParams(qi >= 0 ? reqUrl.slice(qi + 1) : '')
208
+ }
209
+
210
+ function sendJson (res, status, obj) {
211
+ const body = JSON.stringify(obj)
212
+ res.statusCode = status
213
+ res.setHeader('Content-Type', 'application/json')
214
+ res.end(body)
215
+ }
216
+
217
+ async function handleSeries (req, res) {
218
+ if (!available()) return sendJson(res, 503, { ok: false, code: 'history-unavailable', message: 'history not available' })
219
+ const q = query(req.url)
220
+ const windowSec = dur(q.get('window'), 3600, 60, 86400)
221
+ const everySec = dur(q.get('every'), 30, 5, 600)
222
+ try {
223
+ const r = await provider.getSeries({ windowSec, everySec, signal: abortOnClose(res) })
224
+ if (!r.ok) return sendJson(res, r.status || 502, { ok: false, code: 'history-error', message: r.message })
225
+ sendJson(res, 200, { ok: true, windowSec, everySec, from: Date.now() - windowSec * 1000, to: Date.now(), series: r.series })
226
+ } catch (e) {
227
+ sendJson(res, 502, { ok: false, code: 'history-error', message: e.message })
228
+ }
229
+ }
230
+
231
+ async function handleTrack (req, res) {
232
+ if (!available()) return sendJson(res, 503, { ok: false, code: 'history-unavailable', message: 'history not available' })
233
+ const q = query(req.url)
234
+ const windowSec = dur(q.get('window'), 3600, 60, 86400)
235
+ try {
236
+ const r = await provider.getTrack({ windowSec, signal: abortOnClose(res) })
237
+ if (!r.ok) return sendJson(res, r.status || 502, { ok: false, code: 'history-error', message: r.message })
238
+ sendJson(res, 200, { ok: true, windowSec, track: r.track })
239
+ } catch (e) {
240
+ sendJson(res, 502, { ok: false, code: 'history-error', message: e.message })
241
+ }
242
+ }
243
+
244
+ return { start, stop, status, available, handleSeries, handleTrack, _mode: () => mode, _getSeries: influxSeries, _getTrack: influxTrack }
245
+ }
246
+
247
+ module.exports = { createHistory }
@@ -0,0 +1,162 @@
1
+ 'use strict'
2
+
3
+ // DB-less history provider — a rolling ring sampled from the live telemetry
4
+ // BoatState (the same state that feeds /ws/telemetry). The edge path when there's
5
+ // no local InfluxDB (e.g. SignalK on a Victron GX / Venus OS). Serves the SAME
6
+ // series/track contract as the InfluxDB provider; TWS/TWD are derived from apparent
7
+ // wind + boat motion (as the ribbon does), STW is simply absent.
8
+ //
9
+ // Persistence (optional, `persistFile`): a JSONL APPEND-LOG so the ring survives
10
+ // restarts and can cover a long passage cheaply. Each sample is appended as one
11
+ // line (~160 B); the file is compacted (atomic rewrite to the current window) only
12
+ // rarely — on start, once appends exceed the ring length, and on destroy — so write
13
+ // load is per-sample, decoupled from window size (< ~1 GB over a 30-day passage vs
14
+ // ~600 GB for a full-rewrite-every-2min snapshot). To keep RAM/disk bounded at any
15
+ // window, the sample rate auto-coarsens so the ring never exceeds ~MAX_SAMPLES rows
16
+ // (24 h → 15 s, 30 d → ~52 s). All fs is guarded — a missing/corrupt file just means
17
+ // an empty start; appends + compactions are serialized so they can't interleave.
18
+
19
+ const fs = require('fs')
20
+ const fsp = fs.promises
21
+ const path = require('path')
22
+
23
+ const DEG = Math.PI / 180
24
+ const wrap360 = (d) => ((d % 360) + 360) % 360
25
+ const MAX_SAMPLES = 50000
26
+
27
+ // True wind (TWS kt, TWD ° the wind blows FROM) from apparent + motion over ground.
28
+ function trueWind (s) {
29
+ if (!Number.isFinite(s.awsKt) || !Number.isFinite(s.awaDeg) || !Number.isFinite(s.headingDeg)) return null
30
+ const a = s.awaDeg * DEG
31
+ const x = s.awsKt * Math.cos(a) - (s.sogKt || 0); const y = s.awsKt * Math.sin(a)
32
+ return { tws: Math.hypot(x, y), twd: wrap360(s.headingDeg + Math.atan2(y, x) / DEG) }
33
+ }
34
+
35
+ class RingHistoryProvider {
36
+ constructor ({ source, sampleSec, windowSec, persistFile } = {}) {
37
+ this._source = source
38
+ this._ring = [] // [{ t, sog, cog, heading, aws, awa, depth, tws, twd, lat, lon }]
39
+ const win = windowSec || 3600
40
+ this._windowMs = win * 1000
41
+ this._persistFile = persistFile || null
42
+ this._io = Promise.resolve() // serialized fs writes (append/compact never interleave)
43
+ this._appendedSinceCompact = 0
44
+ // auto-coarsen so the ring is bounded (~MAX_SAMPLES rows) regardless of window
45
+ const stepSec = Math.max(sampleSec || 15, Math.ceil(win / MAX_SAMPLES))
46
+ this._stepSec = stepSec
47
+
48
+ this._load() // seed from disk (survives restart)
49
+ this._compactSync() // rewrite clean + bounded, synchronously (file exists right at start)
50
+ this._sample() // immediate first sample
51
+ this._timer = setInterval(() => this._sample(), stepSec * 1000)
52
+ if (this._timer.unref) this._timer.unref()
53
+ }
54
+
55
+ // --- sampling ---
56
+ _sample () {
57
+ const s = this._source && this._source.getState && this._source.getState()
58
+ if (!s) return
59
+ const tw = trueWind(s)
60
+ const num = (v) => (Number.isFinite(v) ? v : null)
61
+ const row = {
62
+ t: Date.now(),
63
+ sog: num(s.sogKt), cog: num(s.cogDeg), heading: num(s.headingDeg),
64
+ aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
65
+ tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
66
+ lat: num(s.lat), lon: num(s.lon)
67
+ }
68
+ this._ring.push(row)
69
+ const cutoff = Date.now() - this._windowMs
70
+ while (this._ring.length && this._ring[0].t < cutoff) this._ring.shift()
71
+ this._append(row)
72
+ }
73
+
74
+ available () { return true }
75
+
76
+ _window (windowSec) {
77
+ const cutoff = Date.now() - Math.min(windowSec * 1000, this._windowMs)
78
+ return this._ring.filter((r) => r.t >= cutoff)
79
+ }
80
+
81
+ getSeries ({ windowSec } = {}) {
82
+ const rows = this._window(windowSec)
83
+ const chans = ['tws', 'twd', 'aws', 'awa', 'sog', 'heading', 'depth'] // no STW in BoatState
84
+ const series = {}
85
+ for (const c of chans) {
86
+ const pts = rows.filter((r) => r[c] != null).map((r) => [r.t, r[c]])
87
+ if (pts.length) series[c] = pts
88
+ }
89
+ return { ok: true, series }
90
+ }
91
+
92
+ getTrack ({ windowSec } = {}) {
93
+ const track = this._window(windowSec)
94
+ .filter((r) => r.lat != null && r.lon != null)
95
+ .map((r) => ({ t: r.t, lat: r.lat, lon: r.lon }))
96
+ return { ok: true, track }
97
+ }
98
+
99
+ // --- persistence ---
100
+ _load () {
101
+ if (!this._persistFile) return
102
+ let text
103
+ try { text = fs.readFileSync(this._persistFile, 'utf8') } catch { return }
104
+ const cutoff = Date.now() - this._windowMs
105
+ let rows = []
106
+ for (const line of text.split('\n')) {
107
+ if (!line) continue
108
+ let r
109
+ try { r = JSON.parse(line) } catch { continue } // skip a torn/corrupt line
110
+ if (r && typeof r.t === 'number' && r.t >= cutoff) rows.push(r)
111
+ }
112
+ rows.sort((a, b) => a.t - b.t)
113
+ if (rows.length > MAX_SAMPLES) rows = rows.slice(-MAX_SAMPLES)
114
+ this._ring = rows
115
+ }
116
+
117
+ _append (row) {
118
+ if (!this._persistFile) return
119
+ const line = JSON.stringify(row) + '\n'
120
+ this._io = this._io.then(() => fsp.appendFile(this._persistFile, line)).catch(() => {})
121
+ // compact once the log has grown by ~a full window past its last rewrite
122
+ if (++this._appendedSinceCompact > Math.max(64, this._ring.length)) this._compact()
123
+ }
124
+
125
+ _serialize () {
126
+ return this._ring.map((r) => JSON.stringify(r)).join('\n') + (this._ring.length ? '\n' : '')
127
+ }
128
+
129
+ _compact () {
130
+ if (!this._persistFile) return
131
+ this._appendedSinceCompact = 0
132
+ const data = this._serialize()
133
+ const file = this._persistFile
134
+ this._io = this._io.then(async () => {
135
+ await fsp.mkdir(path.dirname(file), { recursive: true })
136
+ const tmp = `${file}.tmp-${process.pid}`
137
+ await fsp.writeFile(tmp, data)
138
+ await fsp.rename(tmp, file)
139
+ }).catch(() => {})
140
+ }
141
+
142
+ // synchronous compaction — only at construct, so the file exists immediately.
143
+ _compactSync () {
144
+ if (!this._persistFile) return
145
+ this._appendedSinceCompact = 0
146
+ try {
147
+ fs.mkdirSync(path.dirname(this._persistFile), { recursive: true })
148
+ const tmp = `${this._persistFile}.tmp-${process.pid}`
149
+ fs.writeFileSync(tmp, this._serialize())
150
+ fs.renameSync(tmp, this._persistFile)
151
+ } catch {}
152
+ }
153
+
154
+ _flush () { return this._io } // tests: await all pending writes
155
+
156
+ destroy () {
157
+ clearInterval(this._timer)
158
+ this._compact() // final flush of the current ring
159
+ }
160
+ }
161
+
162
+ module.exports = { RingHistoryProvider, MAX_SAMPLES }
@@ -0,0 +1,151 @@
1
+ 'use strict'
2
+
3
+ // Caching mirror of a single upstream (the sailkick host). getResource() serves
4
+ // a path from the on-disk store if present, else fetches it from the upstream,
5
+ // stores it (bytes + content-type sidecar) atomically, and serves it — so any
6
+ // resource (tiles, JSON, app assets) is fetched once online then served forever
7
+ // offline. The Node/proxy_store equivalent, generalized beyond tiles.
8
+ //
9
+ // Freshness (v0.6.0): tiles are PINNED — never expired by time. A cached file is
10
+ // only treated as stale when its family's bake was re-announced by the cloud (see
11
+ // manifest.js): callers pass `invalidatedAt` = the ms timestamp at which a newer
12
+ // bake for this path's family was learned. A file older than that is refetched
13
+ // lazily when online (UPDATED) or served stale when offline (STALE). No blind TTLs.
14
+
15
+ const fs = require('fs')
16
+ const fsp = fs.promises
17
+ const path = require('path')
18
+ const crypto = require('crypto')
19
+
20
+ // Map a request path (possibly with a query string) to on-disk file paths.
21
+ // Path structure is mirrored for inspectability; a query string is folded into
22
+ // the filename via a short hash so different query params cache separately.
23
+ function storePaths (storeDir, reqPath) {
24
+ const qIdx = reqPath.indexOf('?')
25
+ let p = qIdx >= 0 ? reqPath.slice(0, qIdx) : reqPath
26
+ const q = qIdx >= 0 ? reqPath.slice(qIdx + 1) : ''
27
+ p = p.replace(/^\/+/, '') || 'index'
28
+ let rel = p.replace(/[^a-zA-Z0-9._/-]/g, '_').replace(/\.\.(\/|$)/g, '')
29
+ if (q) rel += '__q_' + crypto.createHash('sha1').update(q).digest('hex').slice(0, 16)
30
+ const file = path.resolve(storeDir, rel)
31
+ // safety: never escape the store dir
32
+ if (!file.startsWith(path.resolve(storeDir) + path.sep)) {
33
+ throw new Error('invalid path')
34
+ }
35
+ return { file, meta: file + '.ct' }
36
+ }
37
+
38
+ async function readFromDisk (file, meta) {
39
+ const buffer = await fsp.readFile(file)
40
+ let contentType = 'application/octet-stream'
41
+ try { contentType = (await fsp.readFile(meta, 'utf8')).trim() || contentType } catch {}
42
+ return { buffer, contentType }
43
+ }
44
+
45
+ // Fetch from upstream and persist atomically (bytes + content-type sidecar). The
46
+ // rename stamps the file mtime ~now, which is what freshness compares against.
47
+ async function fetchAndStore (url, file, meta, timeoutMs) {
48
+ let resp
49
+ try {
50
+ resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
51
+ } catch (e) {
52
+ const err = new Error(`offline / unreachable: ${e.message}`); err.offline = true; throw err
53
+ }
54
+ if (!resp.ok) {
55
+ if (resp.status === 404) {
56
+ // Negative-cache empty tiles (sparse layers — coastline/seamap — 404 on empty
57
+ // tiles) with a sentinel, so offline they read as "empty" (404) instead of
58
+ // hitting the circuit breaker, and a re-seed skips them with no network.
59
+ await fsp.mkdir(path.dirname(file), { recursive: true }).catch(() => {})
60
+ await fsp.writeFile(file + '.404', '').catch(() => {})
61
+ }
62
+ const err = new Error(`upstream HTTP ${resp.status}`); err.status = resp.status; throw err
63
+ }
64
+ const contentType = resp.headers.get('content-type') || 'application/octet-stream'
65
+ const buffer = Buffer.from(await resp.arrayBuffer())
66
+ await fsp.mkdir(path.dirname(file), { recursive: true })
67
+ const tmp = `${file}.tmp-${process.pid}-${buffer.length}`
68
+ await fsp.writeFile(tmp, buffer)
69
+ await fsp.rename(tmp, file)
70
+ await fsp.writeFile(meta, contentType).catch(() => {})
71
+ return { buffer, contentType }
72
+ }
73
+
74
+ // opts: { storeDir, upstream, reqPath, timeoutMs, invalidatedAt, networkFirst, health }
75
+ // returns { buffer, contentType, cacheState, fromCache }; throws (err.offline /
76
+ // err.status) only when neither cached nor fetchable.
77
+ //
78
+ // Two strategies:
79
+ // cache-first (default; static assets + tiles) — serve disk if present, pinned
80
+ // until a bake is announced (invalidatedAt). HIT / UPDATED / STALE / MISS.
81
+ // networkFirst (dynamic /api data — AIS, weather) — fetch LIVE each time, fall
82
+ // back to the cached copy only when offline (STALE). Never serves stale live
83
+ // data while online.
84
+ //
85
+ // `health` is a shared { downUntil, cooldownMs } circuit breaker: once a fetch
86
+ // fails offline, the upstream is treated as down for cooldownMs, so subsequent
87
+ // uncached requests FAIL FAST (no per-request timeout hang that would starve the
88
+ // browser's connection pool) — cached HITs are unaffected. A success clears it.
89
+ async function getResource (opts) {
90
+ const { storeDir, upstream, reqPath, timeoutMs = 20000, invalidatedAt = 0, networkFirst = false, health = null } = opts
91
+ const { file, meta } = storePaths(storeDir, reqPath)
92
+ const url = upstream.replace(/\/+$/, '') + reqPath
93
+ const downNow = !!(health && Date.now() < health.downUntil)
94
+
95
+ const serveDisk = async (state) => ({ ...(await readFromDisk(file, meta)), cacheState: state, fromCache: true })
96
+ const tryFetch = async (state) => {
97
+ const r = await fetchAndStore(url, file, meta, timeoutMs)
98
+ if (health) health.downUntil = 0 // upstream answered → clear the breaker
99
+ return { ...r, cacheState: state, fromCache: false }
100
+ }
101
+ const noteFail = (e) => { if (health && e.offline) health.downUntil = Date.now() + (health.cooldownMs || 15000) }
102
+ const offline = (msg) => { const e = new Error(msg); e.offline = true; return e }
103
+
104
+ if (networkFirst) {
105
+ const stat = await fsp.stat(file).catch(() => null)
106
+ if (downNow) { if (stat) return serveDisk('STALE'); throw offline('offline / not cached') }
107
+ try { return await tryFetch('LIVE') } catch (e) { noteFail(e); if (stat) return serveDisk('STALE'); throw e }
108
+ }
109
+
110
+ // cache-first
111
+ const stat = await fsp.stat(file).catch(() => null)
112
+ if (stat) {
113
+ const stale = invalidatedAt && stat.mtimeMs < invalidatedAt
114
+ if (!stale) return serveDisk('HIT')
115
+ // A newer bake was announced; the on-disk copy predates it.
116
+ if (downNow) return serveDisk('STALE')
117
+ try { return await tryFetch('UPDATED') } catch (e) { noteFail(e); return serveDisk('STALE') }
118
+ }
119
+ // not cached — but maybe negative-cached (known-empty tile)
120
+ const emptyStat = await fsp.stat(file + '.404').catch(() => null)
121
+ if (emptyStat && !(invalidatedAt && emptyStat.mtimeMs < invalidatedAt)) {
122
+ const err = new Error('empty (404, cached)'); err.status = 404; throw err // no fetch
123
+ }
124
+ if (downNow) throw offline('offline / not cached (circuit open)') // fast-fail — don't hang the pool
125
+ try { return await tryFetch('MISS') } catch (e) { noteFail(e); throw e }
126
+ }
127
+
128
+ // Manual force-refresh helper. Two modes:
129
+ // { prefix: 'tiles/osm-standard' } — delete that subtree (a specific tileset)
130
+ // { keep: ['tiles', 'terrain'] } — delete all top-level entries EXCEPT these
131
+ // Deleted files re-fetch fresh on next request. Returns { removed }.
132
+ async function clearStore ({ storeDir, keep = [], prefix = '' } = {}) {
133
+ const root = path.resolve(storeDir)
134
+ if (prefix) {
135
+ const target = path.resolve(root, String(prefix).replace(/^\/+/, ''))
136
+ if (target !== root && !target.startsWith(root + path.sep)) throw new Error('invalid prefix')
137
+ await fsp.rm(target, { recursive: true, force: true })
138
+ return { removed: 1, mode: 'prefix', prefix }
139
+ }
140
+ const keepSet = new Set(keep)
141
+ const entries = await fsp.readdir(root).catch(() => [])
142
+ let removed = 0
143
+ for (const e of entries) {
144
+ if (keepSet.has(e)) continue
145
+ await fsp.rm(path.join(root, e), { recursive: true, force: true })
146
+ removed++
147
+ }
148
+ return { removed, mode: 'keep', kept: [...keepSet] }
149
+ }
150
+
151
+ module.exports = { getResource, storePaths, clearStore }