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,393 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const http = require('http')
|
|
5
|
+
const net = require('net')
|
|
6
|
+
const { getResource, clearStore } = require('./cache')
|
|
7
|
+
const { createManifest } = require('./manifest')
|
|
8
|
+
const { createSeeder } = require('./seed')
|
|
9
|
+
const { countBboxTiles, bboxTiles, boxAround } = require('./tiles')
|
|
10
|
+
|
|
11
|
+
// Caching proxy module. The standalone server (origin root, `proxyPort`) is what
|
|
12
|
+
// the browser points at. It routes by path:
|
|
13
|
+
// - localPaths (default /signalk) -> LOCAL SignalK (live telemetry, no cache),
|
|
14
|
+
// including the WebSocket stream (transparent upgrade relay);
|
|
15
|
+
// - everything else -> mirror the sailkick host, GET cached (offline-first),
|
|
16
|
+
// non-GET live pass-through.
|
|
17
|
+
// This makes the app (served from the mirror, connecting same-origin to SignalK)
|
|
18
|
+
// get its live data from the boat's local SignalK and its charts/weather cached.
|
|
19
|
+
// Also exposes the auth'd /plugins/sailkick-boat/p/* router handlers.
|
|
20
|
+
|
|
21
|
+
function createProxy (app, options) {
|
|
22
|
+
const log = (m) => (app.debug ? app.debug('[proxy] ' + m) : console.log('[sailkick-boat:proxy]', m))
|
|
23
|
+
let cfg = null
|
|
24
|
+
let server = null
|
|
25
|
+
let manifest = null
|
|
26
|
+
let seeder = null
|
|
27
|
+
let area = null // "download around the boat" progress
|
|
28
|
+
let stopping = false
|
|
29
|
+
// Shared upstream circuit breaker: once a fetch fails offline, uncached requests
|
|
30
|
+
// fast-fail for cooldownMs instead of each hanging on the fetch timeout (which
|
|
31
|
+
// would starve the browser's ~6-connection pool and block cached tiles too).
|
|
32
|
+
const health = { downUntil: 0, cooldownMs: 15000 }
|
|
33
|
+
|
|
34
|
+
function start () {
|
|
35
|
+
if (!options.sailkickUrl) { log('not started — no sailkickUrl configured'); return }
|
|
36
|
+
stopping = false
|
|
37
|
+
const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
|
|
38
|
+
cfg = {
|
|
39
|
+
upstream: options.sailkickUrl.replace(/\/+$/, ''),
|
|
40
|
+
storeDir: options.storeDir || path.join(dataDir, 'store'),
|
|
41
|
+
timeoutMs: options.requestTimeoutMs || 20000,
|
|
42
|
+
localSignalk: (options.localSignalkUrl || 'http://127.0.0.1:3000').replace(/\/+$/, ''),
|
|
43
|
+
localPaths: (options.localPaths && options.localPaths.length) ? options.localPaths : ['/signalk'],
|
|
44
|
+
telemetryPath: options.telemetryPath || '/ws/telemetry',
|
|
45
|
+
history: options.history || null,
|
|
46
|
+
openAccess: options.openAccess !== false
|
|
47
|
+
}
|
|
48
|
+
log(`mirroring ${cfg.upstream}; local SignalK ${cfg.localSignalk}; store ${cfg.storeDir}`)
|
|
49
|
+
|
|
50
|
+
// Cache-manifest poller: auto-refresh a dataset lazily when the cloud
|
|
51
|
+
// announces a new bake. Tiles are otherwise pinned (no time-based expiry).
|
|
52
|
+
if (!options.manifest || options.manifest.enabled !== false) {
|
|
53
|
+
try {
|
|
54
|
+
manifest = createManifest(app, {
|
|
55
|
+
upstream: cfg.upstream,
|
|
56
|
+
storeDir: cfg.storeDir,
|
|
57
|
+
manifestPath: options.manifest && options.manifest.path,
|
|
58
|
+
pollIntervalSec: options.manifest && options.manifest.pollIntervalSec,
|
|
59
|
+
timeoutMs: cfg.timeoutMs
|
|
60
|
+
})
|
|
61
|
+
manifest.start()
|
|
62
|
+
} catch (e) { log('manifest poller start failed: ' + e.message); manifest = null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Global offline base seeder (coastline + seabed). Fire-and-forget; self-throttles
|
|
66
|
+
// offline via the shared `health` breaker.
|
|
67
|
+
if (!options.seed || options.seed.enabled !== false) {
|
|
68
|
+
try {
|
|
69
|
+
seeder = createSeeder(app, {
|
|
70
|
+
upstream: cfg.upstream,
|
|
71
|
+
storeDir: cfg.storeDir,
|
|
72
|
+
timeoutMs: cfg.timeoutMs,
|
|
73
|
+
health,
|
|
74
|
+
manifest,
|
|
75
|
+
coastlineMaxZoom: options.seed && options.seed.coastlineMaxZoom,
|
|
76
|
+
seabedMaxZoom: options.seed && options.seed.seabedMaxZoom,
|
|
77
|
+
concurrency: options.seed && options.seed.concurrency
|
|
78
|
+
})
|
|
79
|
+
seeder.start()
|
|
80
|
+
} catch (e) { log('seeder start failed: ' + e.message); seeder = null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// "Download around the boat" — config-driven region prefetch (fire-and-forget).
|
|
84
|
+
try { startAreaPrefetch() } catch (e) { log('area prefetch start failed: ' + e.message) }
|
|
85
|
+
|
|
86
|
+
const port = options.proxyPort == null ? 8080 : Number(options.proxyPort)
|
|
87
|
+
if (port > 0) {
|
|
88
|
+
server = http.createServer(serveMirror)
|
|
89
|
+
server.on('upgrade', relayUpgrade) // WebSocket stream -> local SignalK
|
|
90
|
+
server.on('error', (e) => log('mirror server error: ' + e.message))
|
|
91
|
+
server.listen(port, () => log(`mirror on :${port} (local:${cfg.localPaths.join(',')} -> ${cfg.localSignalk}; else -> ${cfg.upstream})`))
|
|
92
|
+
cfg.port = port
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stop () {
|
|
97
|
+
stopping = true
|
|
98
|
+
area = null
|
|
99
|
+
try { if (seeder) seeder.stop() } catch {}
|
|
100
|
+
seeder = null
|
|
101
|
+
try { if (manifest) manifest.stop() } catch {}
|
|
102
|
+
manifest = null
|
|
103
|
+
if (server) {
|
|
104
|
+
try { if (server.closeAllConnections) server.closeAllConnections() } catch {}
|
|
105
|
+
try { server.close() } catch {}
|
|
106
|
+
server = null
|
|
107
|
+
}
|
|
108
|
+
cfg = null
|
|
109
|
+
}
|
|
110
|
+
function status () {
|
|
111
|
+
if (!cfg) return 'proxy: off'
|
|
112
|
+
const m = manifest ? '; ' + manifest.status() : ''
|
|
113
|
+
const s = seeder ? '; ' + seeder.status() : ''
|
|
114
|
+
return `proxy: mirror ${cfg.upstream}${cfg.port ? ' :' + cfg.port : ''}; live -> ${cfg.localSignalk}${m}${s}${areaStatus()}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const isLocal = (p) => cfg.localPaths.some((lp) => p === lp || p.startsWith(lp + '/') || p.startsWith(lp + '?'))
|
|
118
|
+
|
|
119
|
+
async function serveMirror (req, res) {
|
|
120
|
+
if (!cfg) { res.statusCode = 503; res.end('proxy disabled'); return }
|
|
121
|
+
// Local history endpoints, served from the boat's InfluxDB (offline-first, full
|
|
122
|
+
// local dataset). When history isn't configured they fall through to the mirror,
|
|
123
|
+
// so an online boat still gets history from the cloud — never worse than today.
|
|
124
|
+
if (req.method === 'GET' && cfg.history && cfg.history.available()) {
|
|
125
|
+
const pathname = req.url.split('?')[0]
|
|
126
|
+
if (pathname === '/api/history/series') return cfg.history.handleSeries(req, res)
|
|
127
|
+
if (pathname === '/api/history/track') return cfg.history.handleTrack(req, res)
|
|
128
|
+
}
|
|
129
|
+
// /api/config drives the app's login gate + Trends toggle. On the single-tenant
|
|
130
|
+
// boat we serve it with auth turned off (no cloud login over the offline HTTP
|
|
131
|
+
// mirror) and history forced on when we serve it locally. All other config passes
|
|
132
|
+
// through untouched.
|
|
133
|
+
if (req.method === 'GET' && cfg.openAccess && req.url.split('?')[0] === '/api/config') {
|
|
134
|
+
return serveConfig(req, res)
|
|
135
|
+
}
|
|
136
|
+
if (isLocal(req.url)) { return passThrough(req, res, cfg.localSignalk + req.url) } // live SignalK, no cache
|
|
137
|
+
try {
|
|
138
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
139
|
+
const { buffer, contentType, cacheState } = await getResource({
|
|
140
|
+
storeDir: cfg.storeDir, upstream: cfg.upstream, reqPath: req.url, timeoutMs: cfg.timeoutMs,
|
|
141
|
+
invalidatedAt: manifest ? manifest.invalidatedAtFor(req.url) : 0,
|
|
142
|
+
networkFirst: req.url.startsWith('/api/'), // live data (AIS, weather): fresh online, cache-fallback offline
|
|
143
|
+
health
|
|
144
|
+
})
|
|
145
|
+
res.setHeader('Content-Type', contentType)
|
|
146
|
+
res.setHeader('X-Sailkick-Cache', cacheState)
|
|
147
|
+
res.end(req.method === 'HEAD' ? undefined : buffer)
|
|
148
|
+
} else {
|
|
149
|
+
return passThrough(req, res, cfg.upstream + req.url)
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
res.statusCode = e.offline ? 504 : (e.status || 502)
|
|
153
|
+
res.end(e.offline ? 'offline and not cached' : 'proxy error')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Serve /api/config with the login gate neutralized for the boat. Reuses the
|
|
158
|
+
// normal cache path (online fetch / offline cached copy), then rewrites just the
|
|
159
|
+
// auth flag (and historyAvailable when we serve history locally) before sending.
|
|
160
|
+
async function serveConfig (req, res) {
|
|
161
|
+
try {
|
|
162
|
+
const { buffer, contentType, cacheState } = await getResource({
|
|
163
|
+
storeDir: cfg.storeDir, upstream: cfg.upstream, reqPath: req.url, timeoutMs: cfg.timeoutMs,
|
|
164
|
+
invalidatedAt: manifest ? manifest.invalidatedAtFor(req.url) : 0, health
|
|
165
|
+
})
|
|
166
|
+
let out = buffer
|
|
167
|
+
let ct = contentType
|
|
168
|
+
try {
|
|
169
|
+
const j = JSON.parse(buffer.toString('utf8'))
|
|
170
|
+
if (j && typeof j === 'object') {
|
|
171
|
+
j.auth = { ...(j.auth || {}), required: false } // single-tenant boat: no cloud login
|
|
172
|
+
if (cfg.history && cfg.history.available()) j.historyAvailable = true // served locally (InfluxDB or ring)
|
|
173
|
+
out = Buffer.from(JSON.stringify(j))
|
|
174
|
+
ct = 'application/json'
|
|
175
|
+
}
|
|
176
|
+
} catch { /* not JSON — serve as fetched */ }
|
|
177
|
+
res.setHeader('Content-Type', ct)
|
|
178
|
+
res.setHeader('X-Sailkick-Cache', cacheState)
|
|
179
|
+
res.end(req.method === 'HEAD' ? undefined : out)
|
|
180
|
+
} catch (e) {
|
|
181
|
+
res.statusCode = e.offline ? 504 : (e.status || 502)
|
|
182
|
+
res.end(e.offline ? 'offline and not cached' : 'proxy error')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// transparent HTTP relay (no cache) — forwards method/body + key headers
|
|
187
|
+
async function passThrough (req, res, target) {
|
|
188
|
+
const chunks = []
|
|
189
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { for await (const c of req) chunks.push(c) }
|
|
190
|
+
const fwd = {}
|
|
191
|
+
for (const h of ['content-type', 'authorization', 'cookie', 'accept', 'accept-language', 'user-agent']) {
|
|
192
|
+
if (req.headers[h]) fwd[h] = req.headers[h]
|
|
193
|
+
}
|
|
194
|
+
let r
|
|
195
|
+
try {
|
|
196
|
+
r = await fetch(target, { method: req.method, headers: fwd, body: chunks.length ? Buffer.concat(chunks) : undefined })
|
|
197
|
+
} catch (e) { res.statusCode = 502; res.end('upstream unreachable'); return }
|
|
198
|
+
res.statusCode = r.status
|
|
199
|
+
r.headers.forEach((v, k) => {
|
|
200
|
+
const kl = k.toLowerCase()
|
|
201
|
+
if (!['content-encoding', 'content-length', 'transfer-encoding', 'connection'].includes(kl)) {
|
|
202
|
+
try { res.setHeader(k, v) } catch {}
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
const buf = Buffer.from(await r.arrayBuffer())
|
|
206
|
+
res.end(req.method === 'HEAD' ? undefined : buf)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// WebSocket (or any HTTP upgrade) on a local path -> transparent TCP relay to
|
|
210
|
+
// the local SignalK server (so the SignalK live stream works through :8080).
|
|
211
|
+
function relayUpgrade (req, clientSocket, head) {
|
|
212
|
+
if (!cfg) { clientSocket.destroy(); return }
|
|
213
|
+
// /ws/telemetry -> the local telemetry provider (served from local SignalK)
|
|
214
|
+
if (options.telemetryUpgrade && req.url.startsWith(cfg.telemetryPath)) {
|
|
215
|
+
options.telemetryUpgrade(req, clientSocket, head); return
|
|
216
|
+
}
|
|
217
|
+
if (!isLocal(req.url)) { clientSocket.destroy(); return }
|
|
218
|
+
let u
|
|
219
|
+
try { u = new URL(cfg.localSignalk) } catch { clientSocket.destroy(); return }
|
|
220
|
+
const upstream = net.connect(Number(u.port || 80), u.hostname, () => {
|
|
221
|
+
let raw = `${req.method} ${req.url} HTTP/1.1\r\n`
|
|
222
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
|
|
223
|
+
raw += '\r\n'
|
|
224
|
+
upstream.write(raw)
|
|
225
|
+
if (head && head.length) upstream.write(head)
|
|
226
|
+
upstream.pipe(clientSocket)
|
|
227
|
+
clientSocket.pipe(upstream)
|
|
228
|
+
})
|
|
229
|
+
// tear down both sides together so a disconnect never leaks a socket
|
|
230
|
+
const kill = () => { upstream.destroy(); clientSocket.destroy() }
|
|
231
|
+
upstream.on('error', kill); upstream.on('close', kill)
|
|
232
|
+
clientSocket.on('error', kill); clientSocket.on('close', kill)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- auth'd plugin router handlers (mounted at /plugins/sailkick-boat) ---
|
|
236
|
+
async function handleGet (req, res) {
|
|
237
|
+
if (!cfg) { res.status(503).send('proxy disabled'); return }
|
|
238
|
+
const rest = req.params[0] || ''
|
|
239
|
+
const qi = req.originalUrl.indexOf('?')
|
|
240
|
+
const qs = qi >= 0 ? req.originalUrl.slice(qi) : ''
|
|
241
|
+
const reqPath = '/' + rest + qs
|
|
242
|
+
try {
|
|
243
|
+
const { buffer, contentType, cacheState } = await getResource({
|
|
244
|
+
storeDir: cfg.storeDir, upstream: cfg.upstream, reqPath, timeoutMs: cfg.timeoutMs,
|
|
245
|
+
invalidatedAt: manifest ? manifest.invalidatedAtFor(reqPath) : 0,
|
|
246
|
+
networkFirst: reqPath.startsWith('/api/'), health
|
|
247
|
+
})
|
|
248
|
+
res.set('Content-Type', contentType)
|
|
249
|
+
res.set('X-Sailkick-Cache', cacheState)
|
|
250
|
+
res.send(buffer)
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (e.offline) res.status(504).send('offline and not cached')
|
|
253
|
+
else res.status(e.status || 502).send('upstream error')
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// POST /plugins/sailkick-boat/cache/clear — manual force-refresh. Query:
|
|
258
|
+
// ?prefix=tiles/osm-standard delete one tileset subtree
|
|
259
|
+
// ?keep=tiles,terrain delete everything except these (default)
|
|
260
|
+
async function handleClear (req, res) {
|
|
261
|
+
if (!cfg) { res.status(503).send('proxy disabled'); return }
|
|
262
|
+
const prefix = (req.query && req.query.prefix) ? String(req.query.prefix) : ''
|
|
263
|
+
const keep = (req.query && req.query.keep != null)
|
|
264
|
+
? String(req.query.keep).split(',').map((s) => s.trim()).filter(Boolean)
|
|
265
|
+
: ['tiles', 'terrain', 'history'] // 'history' = the persistent ring log lives under storeDir
|
|
266
|
+
try {
|
|
267
|
+
const r = await clearStore({ storeDir: cfg.storeDir, keep, prefix })
|
|
268
|
+
res.json({ ok: true, ...r })
|
|
269
|
+
} catch (e) {
|
|
270
|
+
res.status(400).json({ ok: false, message: e.message })
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Warm a list of paths into the cache, bounded-concurrency, reusing the circuit
|
|
275
|
+
// breaker (so it fast-fails/goes quiet offline). 404s count as `empty` (negative-
|
|
276
|
+
// cached), not failures. Returns { cached, empty, failed }.
|
|
277
|
+
async function warmMany (paths, { concurrency = 6, onProgress } = {}) {
|
|
278
|
+
const arr = Array.isArray(paths) ? paths : [...paths]
|
|
279
|
+
let cached = 0; let empty = 0; let failed = 0; let i = 0
|
|
280
|
+
const worker = async () => {
|
|
281
|
+
while (i < arr.length && !stopping) {
|
|
282
|
+
const p = arr[i++]
|
|
283
|
+
const reqPath = p.startsWith('/') ? p : '/' + p
|
|
284
|
+
try {
|
|
285
|
+
await getResource({ storeDir: cfg.storeDir, upstream: cfg.upstream, reqPath, timeoutMs: cfg.timeoutMs, health, invalidatedAt: manifest ? manifest.invalidatedAtFor(reqPath) : 0 })
|
|
286
|
+
cached++
|
|
287
|
+
} catch (e) { if (e.status === 404) empty++; else failed++ }
|
|
288
|
+
if (onProgress) onProgress()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker))
|
|
292
|
+
return { cached, empty, failed }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
296
|
+
let raw = ''
|
|
297
|
+
req.setEncoding('utf8')
|
|
298
|
+
req.on('data', (c) => { raw += c; if (raw.length > 1e6) req.destroy() })
|
|
299
|
+
req.on('end', () => resolve(raw))
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
function handlePrefetch (req, res) {
|
|
303
|
+
if (!cfg) { res.status(503).send('proxy disabled'); return }
|
|
304
|
+
readBody(req).then(async (raw) => {
|
|
305
|
+
let paths = []
|
|
306
|
+
try { paths = JSON.parse(raw).paths || [] } catch { paths = raw.split(/\s+/).filter(Boolean) }
|
|
307
|
+
const r = await warmMany(paths)
|
|
308
|
+
res.json({ requested: paths.length, ...r })
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// POST /plugins/sailkick-boat/prefetch/region — warm a bbox×zoom×layers rectangle
|
|
313
|
+
// for offline passage coverage. Estimates first and refuses > cap unless force.
|
|
314
|
+
function handlePrefetchRegion (req, res) {
|
|
315
|
+
if (!cfg) { res.status(503).send('proxy disabled'); return }
|
|
316
|
+
const clampInt = (v, def, lo, hi) => { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : def }
|
|
317
|
+
readBody(req).then(async (raw) => {
|
|
318
|
+
let body = {}
|
|
319
|
+
try { body = JSON.parse(raw) } catch { res.status(400).json({ ok: false, message: 'invalid JSON body' }); return }
|
|
320
|
+
const bbox = body.bbox
|
|
321
|
+
if (!Array.isArray(bbox) || bbox.length !== 4 || !bbox.every((n) => Number.isFinite(n))) {
|
|
322
|
+
res.status(400).json({ ok: false, message: 'bbox [w,s,e,n] (numbers) required' }); return
|
|
323
|
+
}
|
|
324
|
+
const minZoom = clampInt(body.minZoom, 0, 0, 20)
|
|
325
|
+
const maxZoom = Math.max(minZoom, clampInt(body.maxZoom, 15, 0, 20))
|
|
326
|
+
const layers = (Array.isArray(body.layers) && body.layers.length) ? body.layers : ['osm-standard', 'bathy', 'seamap', 'coastline']
|
|
327
|
+
const CAP = 50000
|
|
328
|
+
const estimate = countBboxTiles(bbox, minZoom, maxZoom) * layers.length
|
|
329
|
+
if (estimate > CAP && !body.force) {
|
|
330
|
+
res.json({ ok: true, capped: true, estimate, cap: CAP, message: `~${estimate} tiles exceeds ${CAP}; narrow bbox/zoom or pass force:true` })
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const ext = (layer) => (layer === 'coastline' ? 'pbf' : 'png')
|
|
334
|
+
const paths = []
|
|
335
|
+
for (const layer of layers) {
|
|
336
|
+
for (const { z, x, y } of bboxTiles(bbox, minZoom, maxZoom)) paths.push(`/tiles/${layer}/${z}/${x}/${y}.${ext(layer)}`)
|
|
337
|
+
}
|
|
338
|
+
const r = await warmMany(paths, { concurrency: body.concurrency || 6 })
|
|
339
|
+
res.json({ ok: true, requested: paths.length, ...r })
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- "download around the boat" (config-driven region prefetch) ---
|
|
344
|
+
function getBoatPos () {
|
|
345
|
+
try {
|
|
346
|
+
const p = app.getSelfPath && app.getSelfPath('navigation.position')
|
|
347
|
+
const v = p && (p.value || p)
|
|
348
|
+
if (v && Number.isFinite(v.latitude) && Number.isFinite(v.longitude)) return v
|
|
349
|
+
} catch {}
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Kicks off a background prefetch of a radius around the boat's current position at
|
|
354
|
+
// the chosen detail zoom. Retries for a position fix (from local SignalK) for a
|
|
355
|
+
// while, then gives up. Idempotent (cached tiles are skipped). Returns the warming
|
|
356
|
+
// promise (or null) so tests can await it.
|
|
357
|
+
function startAreaPrefetch (attempt = 0) {
|
|
358
|
+
const pf = options.prefetch || {}
|
|
359
|
+
const radius = Number(pf.radiusNm) || 0
|
|
360
|
+
if (radius <= 0 || stopping) return null
|
|
361
|
+
const maxZoom = Number(pf.detailZoom) || 13
|
|
362
|
+
const minZoom = Math.min(6, maxZoom)
|
|
363
|
+
const layers = (Array.isArray(pf.layers) && pf.layers.length) ? pf.layers : ['osm-standard', 'bathy', 'seamap', 'coastline']
|
|
364
|
+
const pos = getBoatPos()
|
|
365
|
+
if (!pos) {
|
|
366
|
+
if (attempt < 30 && !stopping) { const t = setTimeout(() => startAreaPrefetch(attempt + 1), 10000); if (t.unref) t.unref() } else log('area prefetch: no boat position — skipped')
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
const bbox = boxAround(pos.latitude, pos.longitude, radius)
|
|
370
|
+
const CAP = 150000
|
|
371
|
+
const total = countBboxTiles(bbox, minZoom, maxZoom) * layers.length
|
|
372
|
+
if (total > CAP) { area = { capped: true, total, radius, maxZoom }; log(`area prefetch: ~${total} tiles too large — reduce radius/detail`); return null }
|
|
373
|
+
const ext = (l) => (l === 'coastline' ? 'pbf' : 'png')
|
|
374
|
+
const paths = []
|
|
375
|
+
for (const l of layers) { for (const { z, x, y } of bboxTiles(bbox, minZoom, maxZoom)) paths.push(`/tiles/${l}/${z}/${x}/${y}.${ext(l)}`) }
|
|
376
|
+
area = { done: 0, total: paths.length, running: true, radius, maxZoom }
|
|
377
|
+
log(`area prefetch: ${radius}nm around ${pos.latitude.toFixed(2)},${pos.longitude.toFixed(2)} to z${maxZoom} — ${paths.length} tiles`)
|
|
378
|
+
area.promise = warmMany(paths, { concurrency: pf.concurrency || 4, onProgress: () => { area.done++ } })
|
|
379
|
+
.then((r) => { area.running = false; area.result = r; log(`area prefetch done: ${r.cached} cached, ${r.empty} empty, ${r.failed} failed`); return r })
|
|
380
|
+
.catch((e) => { area.running = false; log('area prefetch error: ' + e.message) })
|
|
381
|
+
return area.promise
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function areaStatus () {
|
|
385
|
+
if (!area) return ''
|
|
386
|
+
if (area.capped) return `; area: too large (~${area.total}) — reduce radius/detail`
|
|
387
|
+
return `; area(${area.radius}nm z${area.maxZoom}): ${area.done}/${area.total}${area.running ? '' : ' done'}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { start, stop, status, handleGet, handlePrefetch, handlePrefetchRegion, handleClear, _serveMirror: serveMirror, _serveConfig: serveConfig, _manifest: () => manifest, _seeder: () => seeder, _startAreaPrefetch: startAreaPrefetch, _area: () => area }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
module.exports = { createProxy }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Cache-manifest poller. The cloud sailkick host publishes a small manifest that
|
|
4
|
+
// announces the current bake id per dataset:
|
|
5
|
+
//
|
|
6
|
+
// { "app": "2026-07-19a",
|
|
7
|
+
// "bakes": { "tiles/osm-standard": "v3", "tiles/seamap": "2026-06", "terrain": "2026-01" } }
|
|
8
|
+
//
|
|
9
|
+
// We poll it (only when online — a fetch failure is a no-op), and remember the
|
|
10
|
+
// last-seen id per family plus the moment we first learned a NEW id
|
|
11
|
+
// (invalidatedAt). cache.js treats any cached file older than its family's
|
|
12
|
+
// invalidatedAt as stale → lazy refetch when online, serve-stale when offline.
|
|
13
|
+
// Tiles are otherwise PINNED forever, so there is no per-tile time revalidation.
|
|
14
|
+
//
|
|
15
|
+
// Families come from the `bakes` keys (path prefixes); everything else (the app
|
|
16
|
+
// shell, cached /api GETs) belongs to a synthetic `app` family keyed on `app`.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs')
|
|
19
|
+
const fsp = fs.promises
|
|
20
|
+
const path = require('path')
|
|
21
|
+
|
|
22
|
+
function createManifest (app, options) {
|
|
23
|
+
const log = (m) => (app.debug ? app.debug('[manifest] ' + m) : console.log('[sailkick-boat:manifest]', m))
|
|
24
|
+
let cfg = null
|
|
25
|
+
let timer = null
|
|
26
|
+
let known = {} // family -> { id, invalidatedAt }
|
|
27
|
+
let lastOk = 0
|
|
28
|
+
|
|
29
|
+
function start () {
|
|
30
|
+
cfg = {
|
|
31
|
+
upstream: options.upstream.replace(/\/+$/, ''),
|
|
32
|
+
path: options.manifestPath || '/api/cache-manifest',
|
|
33
|
+
stateFile: path.join(options.storeDir, '.cache-manifest.json'),
|
|
34
|
+
intervalMs: Math.max(30000, (options.pollIntervalSec || 300) * 1000),
|
|
35
|
+
timeoutMs: options.timeoutMs || 15000
|
|
36
|
+
}
|
|
37
|
+
load()
|
|
38
|
+
poll() // immediate first check
|
|
39
|
+
timer = setInterval(poll, cfg.intervalMs)
|
|
40
|
+
if (timer.unref) timer.unref()
|
|
41
|
+
log(`polling ${cfg.upstream}${cfg.path} every ${Math.round(cfg.intervalMs / 1000)}s`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stop () {
|
|
45
|
+
if (timer) clearInterval(timer)
|
|
46
|
+
timer = null
|
|
47
|
+
cfg = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function load () {
|
|
51
|
+
try { known = JSON.parse(fs.readFileSync(cfg.stateFile, 'utf8')) || {} } catch { known = {} }
|
|
52
|
+
}
|
|
53
|
+
async function save () {
|
|
54
|
+
try {
|
|
55
|
+
await fsp.mkdir(path.dirname(cfg.stateFile), { recursive: true })
|
|
56
|
+
await fsp.writeFile(cfg.stateFile, JSON.stringify(known))
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function poll () {
|
|
61
|
+
if (!cfg) return
|
|
62
|
+
let m
|
|
63
|
+
try {
|
|
64
|
+
const resp = await fetch(cfg.upstream + cfg.path, { signal: AbortSignal.timeout(cfg.timeoutMs) })
|
|
65
|
+
if (!resp.ok) return
|
|
66
|
+
m = await resp.json()
|
|
67
|
+
} catch { return } // offline / bad JSON → no-op, nothing invalidated
|
|
68
|
+
lastOk = Date.now()
|
|
69
|
+
|
|
70
|
+
const families = { ...((m && m.bakes) || {}) }
|
|
71
|
+
if (m && m.app != null) families.app = m.app
|
|
72
|
+
|
|
73
|
+
let changed = false
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
for (const [fam, id] of Object.entries(families)) {
|
|
76
|
+
const sid = String(id)
|
|
77
|
+
const prev = known[fam]
|
|
78
|
+
if (!prev) {
|
|
79
|
+
// First sight of a family: record the id but DON'T invalidate — the boat's
|
|
80
|
+
// pre-populated store is valid for whatever it already has.
|
|
81
|
+
known[fam] = { id: sid, invalidatedAt: 0 }
|
|
82
|
+
changed = true
|
|
83
|
+
} else if (prev.id !== sid) {
|
|
84
|
+
known[fam] = { id: sid, invalidatedAt: now }
|
|
85
|
+
changed = true
|
|
86
|
+
log(`bake changed: ${fam} ${prev.id} -> ${sid} (older files refresh lazily)`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (changed) await save()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Family of a request path = the longest matching bake prefix, else 'app'.
|
|
93
|
+
function familyFor (reqPath) {
|
|
94
|
+
const p = reqPath.replace(/^\/+/, '').split('?')[0]
|
|
95
|
+
let best = null
|
|
96
|
+
for (const fam of Object.keys(known)) {
|
|
97
|
+
if (fam === 'app') continue
|
|
98
|
+
const pref = fam.replace(/\/+$/, '')
|
|
99
|
+
if (p === pref || p.startsWith(pref + '/')) {
|
|
100
|
+
if (!best || pref.length > best.length) best = pref
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best || 'app'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function invalidatedAtFor (reqPath) {
|
|
107
|
+
const fam = familyFor(reqPath)
|
|
108
|
+
return (known[fam] && known[fam].invalidatedAt) || 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function status () {
|
|
112
|
+
if (!cfg) return 'manifest: off'
|
|
113
|
+
const n = Object.keys(known).length
|
|
114
|
+
const age = lastOk ? Math.round((Date.now() - lastOk) / 1000) + 's ago' : 'never'
|
|
115
|
+
return `manifest: ${n} families, synced ${age}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { start, stop, status, invalidatedAtFor, familyFor, _poll: poll, _known: () => known }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { createManifest }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Global offline base seeder. Warms two worldwide layers into the mirror cache so a
|
|
4
|
+
// usable map exists offline everywhere (not just where you browsed):
|
|
5
|
+
// - seabed = the `bathy` raster (dense global): enumerate z0..seabedMaxZoom fully.
|
|
6
|
+
// - coastline = sparse vector .pbf: parent-guided descent from z0 — only descend
|
|
7
|
+
// into non-empty tiles (empty tiles 404 → negative-cached, not descended). Coast
|
|
8
|
+
// is continuous across zoom, so every non-empty child has a non-empty parent, so
|
|
9
|
+
// the descent is complete while probing ~4x the real tiles (not the full pyramid).
|
|
10
|
+
//
|
|
11
|
+
// Warms via getResource (cache-first) so it is idempotent/resumable: already-cached
|
|
12
|
+
// tiles and negative-cached empties cost no network. Reuses the shared `health`
|
|
13
|
+
// breaker — goes quiet when offline and resumes when back online. Fire-and-forget.
|
|
14
|
+
|
|
15
|
+
const { getResource } = require('./cache')
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref() })
|
|
18
|
+
|
|
19
|
+
function createSeeder (app, opts) {
|
|
20
|
+
const log = (m) => (app.debug ? app.debug('[seed] ' + m) : console.log('[sailkick-boat:seed]', m))
|
|
21
|
+
let cfg = null
|
|
22
|
+
let running = false
|
|
23
|
+
let stopped = false
|
|
24
|
+
let runPromise = null
|
|
25
|
+
const counts = { coastline: 0, coastEmpty: 0, seabed: 0, seabedEmpty: 0, failed: 0 }
|
|
26
|
+
|
|
27
|
+
function start () {
|
|
28
|
+
cfg = {
|
|
29
|
+
upstream: opts.upstream.replace(/\/+$/, ''),
|
|
30
|
+
storeDir: opts.storeDir,
|
|
31
|
+
timeoutMs: opts.timeoutMs || 20000,
|
|
32
|
+
health: opts.health || null,
|
|
33
|
+
manifest: opts.manifest || null,
|
|
34
|
+
coastlineMaxZoom: opts.coastlineMaxZoom == null ? 8 : opts.coastlineMaxZoom,
|
|
35
|
+
seabedMaxZoom: opts.seabedMaxZoom == null ? 6 : opts.seabedMaxZoom,
|
|
36
|
+
concurrency: Math.max(1, opts.concurrency || 4),
|
|
37
|
+
offlinePollMs: opts.offlinePollMs || 2000
|
|
38
|
+
}
|
|
39
|
+
stopped = false
|
|
40
|
+
runPromise = run().catch((e) => log('seed error: ' + e.message))
|
|
41
|
+
return runPromise
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stop () { stopped = true }
|
|
45
|
+
|
|
46
|
+
function status () {
|
|
47
|
+
if (!cfg) return 'seed: off'
|
|
48
|
+
return `seed: coastline ${counts.coastline}, seabed ${counts.seabed} (${running ? 'running' : 'done'})`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function waitIfOffline () {
|
|
52
|
+
while (!stopped && cfg.health && Date.now() < cfg.health.downUntil) await sleep(cfg.offlinePollMs)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Warm one tile. Retries indefinitely while offline (circuit open) so the seed
|
|
56
|
+
// survives connectivity gaps. Returns {ok} | {empty} (404) | {failed} | {stopped}.
|
|
57
|
+
async function warm (reqPath) {
|
|
58
|
+
for (;;) {
|
|
59
|
+
await waitIfOffline()
|
|
60
|
+
if (stopped) return { stopped: true }
|
|
61
|
+
try {
|
|
62
|
+
await getResource({
|
|
63
|
+
storeDir: cfg.storeDir,
|
|
64
|
+
upstream: cfg.upstream,
|
|
65
|
+
reqPath,
|
|
66
|
+
timeoutMs: cfg.timeoutMs,
|
|
67
|
+
health: cfg.health,
|
|
68
|
+
invalidatedAt: cfg.manifest ? cfg.manifest.invalidatedAtFor(reqPath) : 0
|
|
69
|
+
})
|
|
70
|
+
return { ok: true }
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (e.status === 404) return { empty: true }
|
|
73
|
+
if (e.offline) { await sleep(cfg.offlinePollMs); continue } // went offline mid-flight → wait + retry
|
|
74
|
+
return { failed: true } // e.g. a 5xx — skip this tile
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pool (worker) {
|
|
80
|
+
await Promise.all(Array.from({ length: cfg.concurrency }, () => worker()))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function seedSeabed () {
|
|
84
|
+
const tiles = []
|
|
85
|
+
for (let z = 0; z <= cfg.seabedMaxZoom; z++) {
|
|
86
|
+
const n = 2 ** z
|
|
87
|
+
for (let x = 0; x < n; x++) for (let y = 0; y < n; y++) tiles.push([z, x, y])
|
|
88
|
+
}
|
|
89
|
+
let i = 0
|
|
90
|
+
await pool(async () => {
|
|
91
|
+
while (!stopped && i < tiles.length) {
|
|
92
|
+
const [z, x, y] = tiles[i++]
|
|
93
|
+
const r = await warm(`/tiles/bathy/${z}/${x}/${y}.png`)
|
|
94
|
+
if (r.stopped) return
|
|
95
|
+
if (r.ok) counts.seabed++; else if (r.empty) counts.seabedEmpty++; else counts.failed++
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function seedCoastline () {
|
|
101
|
+
const queue = [[0, 0, 0]]
|
|
102
|
+
let active = 0
|
|
103
|
+
await pool(async () => {
|
|
104
|
+
while (!stopped) {
|
|
105
|
+
const t = queue.shift() // shift + active++ are synchronous (no await between) → atomic
|
|
106
|
+
if (!t) { if (active === 0) return; await sleep(50); continue }
|
|
107
|
+
active++
|
|
108
|
+
const [z, x, y] = t
|
|
109
|
+
const r = await warm(`/tiles/coastline/${z}/${x}/${y}.pbf`)
|
|
110
|
+
if (r.stopped) { active--; return }
|
|
111
|
+
if (r.ok) {
|
|
112
|
+
counts.coastline++
|
|
113
|
+
if (z < cfg.coastlineMaxZoom) {
|
|
114
|
+
const nz = z + 1; const nx = x * 2; const ny = y * 2
|
|
115
|
+
queue.push([nz, nx, ny], [nz, nx + 1, ny], [nz, nx, ny + 1], [nz, nx + 1, ny + 1])
|
|
116
|
+
}
|
|
117
|
+
} else if (r.empty) counts.coastEmpty++
|
|
118
|
+
else counts.failed++
|
|
119
|
+
active--
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function run () {
|
|
125
|
+
running = true
|
|
126
|
+
log(`seeding: coastline z0-${cfg.coastlineMaxZoom}, seabed z0-${cfg.seabedMaxZoom}, concurrency ${cfg.concurrency}`)
|
|
127
|
+
try {
|
|
128
|
+
await seedSeabed()
|
|
129
|
+
await seedCoastline()
|
|
130
|
+
if (!stopped) log(`seed complete: coastline ${counts.coastline} (+${counts.coastEmpty} empty), seabed ${counts.seabed} (+${counts.seabedEmpty} empty), failed ${counts.failed}`)
|
|
131
|
+
} finally {
|
|
132
|
+
running = false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { start, stop, status, _wait: () => runPromise, _counts: () => counts }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { createSeeder }
|