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.
- package/LICENSE +11 -0
- package/README.md +197 -0
- package/index.js +252 -0
- package/lib/account/index.js +65 -0
- package/lib/history/index.js +247 -0
- package/lib/history/ring.js +162 -0
- package/lib/proxy/cache.js +151 -0
- package/lib/proxy/index.js +393 -0
- package/lib/proxy/manifest.js +121 -0
- package/lib/proxy/seed.js +139 -0
- package/lib/proxy/tiles.js +62 -0
- package/lib/sync/index.js +150 -0
- package/lib/sync/influxWrite.js +49 -0
- package/lib/sync/lineprotocol.js +121 -0
- package/lib/sync/spool.js +101 -0
- package/lib/telemetry/index.js +123 -0
- package/lib/telemetry/signalk-map.js +71 -0
- package/package.json +41 -0
|
@@ -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 }
|