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,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 }