sailkick-boat 0.23.0 → 0.23.3

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/README.md CHANGED
@@ -52,6 +52,17 @@ files to send later — nothing is lost in the gap.
52
52
 
53
53
  - Network errors, `429` and `5xx` are retried with backoff (1 s → 60 s); the data stays
54
54
  on disk.
55
+ - **All cloud traffic uses core `https`, not `fetch`** — telemetry sync, the offline
56
+ mirror, the cache-manifest poller, the contract check, the backfill and the Sync page
57
+ share one connection pool (`lib/net.js`), so one reset clears every subsystem. Twice in one afternoon this boat's
58
+ Signal K process stopped being able to open *any* outbound HTTPS connection — zero
59
+ sockets to `:443`, while a second process in the same container reached the same host
60
+ in under a second — and it never recovered on its own. Starlink sits behind CGNAT,
61
+ which drops idle NAT mappings without an RST, so a pooled keep-alive socket looks alive
62
+ to the client and is dead on the wire. `fetch` offers no supported way to reset its
63
+ pool from a plugin. Owning an agent means the pool can be rebuilt after repeated
64
+ transport failures (and it is, automatically, after five), and it means the log names
65
+ `ECONNRESET` or `ETIMEDOUT` instead of `fetch`'s uniformly useless "fetch failed".
55
66
  - A malformed batch (`4xx` other than the three below) is quarantined to `spool/dead/`
56
67
  rather than retried forever, because it would otherwise wedge the queue behind it.
57
68
  - **`404`, `401` and `403` are held, not quarantined.** A missing bucket or a rejected
@@ -24,6 +24,7 @@ const fs = require('fs')
24
24
  const path = require('path')
25
25
  const { writeLines } = require('../sync/influxWrite')
26
26
  const { csvToLineProtocol } = require('./lineproto')
27
+ const { request } = require('../net')
27
28
 
28
29
  const HOUR_MS = 3600000
29
30
  const DEFAULTS = {
@@ -121,13 +122,13 @@ function createBackfill (app, options) {
121
122
  const url = `${conn.url.replace(/\/+$/, '')}/api/v2/query?org=${encodeURIComponent(conn.org)}`
122
123
  let resp
123
124
  try {
124
- resp = await fetch(url, {
125
+ resp = await request(url, {
125
126
  method: 'POST',
126
127
  headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/json', Accept: 'application/csv' },
127
128
  body: JSON.stringify({ type: 'flux', query: body, dialect: { header: true, annotations: ['datatype'] } }),
128
- signal: AbortSignal.timeout(cfg.queryTimeoutMs)
129
+ timeoutMs: cfg.queryTimeoutMs
129
130
  })
130
- } catch (e) { return { ok: false, message: e.message } }
131
+ } catch (e) { return { ok: false, message: `${e.message}${e.code ? ` (${e.code})` : ''}` } }
131
132
  if (!resp.ok) {
132
133
  const t = await resp.text().catch(() => '')
133
134
  return { ok: false, status: resp.status, message: `HTTP ${resp.status}: ${t.slice(0, 160)}` }
@@ -27,6 +27,8 @@
27
27
  const fs = require('fs')
28
28
  const fsp = fs.promises
29
29
  const path = require('path')
30
+ // Named to avoid shadowing this module's own request() below.
31
+ const { request: httpRequest } = require('../net')
30
32
 
31
33
  const COOKIE_NAME = 'sk_session'
32
34
  const MAX_BODY_BYTES = 8 * 1024 * 1024 // a polar CSV is tiny; this is just a sane ceiling
@@ -105,11 +107,11 @@ function createCloud (app, options = {}) {
105
107
 
106
108
  async function login (slug, password) {
107
109
  if (!upstream) return { ok: false, code: 'no-upstream', message: 'no cloud host configured' }
108
- const got = await attempt(() => fetch(`${upstream}/api/auth/login`, {
110
+ const got = await attempt(() => httpRequest(`${upstream}/api/auth/login`, {
109
111
  method: 'POST',
110
112
  headers: { 'Content-Type': 'application/json' },
111
113
  body: JSON.stringify({ slug, password }),
112
- signal: AbortSignal.timeout(timeoutMs)
114
+ timeoutMs
113
115
  }))
114
116
  if (!got.ok) {
115
117
  warn(`login could not reach ${upstream} after 3 attempts — ${why(got.error)}`)
@@ -151,15 +153,15 @@ function createCloud (app, options = {}) {
151
153
  // handler and a boat is offline most of the time.
152
154
  async function request (apiPath, { method = 'GET', body = null } = {}) {
153
155
  if (!session) return { ok: false, status: 401, code: 'logged-out', message: 'not logged in to the cloud' }
154
- const got = await attempt(() => fetch(upstream + apiPath, {
156
+ const got = await attempt(() => httpRequest(upstream + apiPath, {
155
157
  method,
156
158
  headers: {
157
159
  Cookie: session.cookie,
158
160
  Accept: 'application/json',
159
161
  ...(body ? { 'Content-Type': 'application/json' } : {})
160
162
  },
161
- body: body || undefined,
162
- signal: AbortSignal.timeout(timeoutMs)
163
+ body: body || null,
164
+ timeoutMs
163
165
  }))
164
166
  if (!got.ok) {
165
167
  return { ok: false, status: 0, code: 'offline', message: `cannot reach the cloud — ${why(got.error)}. The boat's link may have dropped; try again.` }
@@ -51,6 +51,7 @@ function createHistory (app, options) {
51
51
  const persistFile = options.ringPersist !== false ? path.join(ringDir, 'history-ring.jsonl') : null
52
52
  provider = new RingHistoryProvider({
53
53
  source: options.ringSource,
54
+ perfSource: options.perfSource || null, // lib/perf — the computed polar %
54
55
  windowSec: options.ringWindowSec,
55
56
  sampleSec: options.ringSampleSec,
56
57
  persistFile
package/lib/net.js ADDED
@@ -0,0 +1,149 @@
1
+ 'use strict'
2
+
3
+ // The one outbound HTTP client for everything that talks to the cloud.
4
+ //
5
+ // WHY NOT fetch()
6
+ //
7
+ // Twice in one afternoon this boat's Signal K process stopped being able to open ANY
8
+ // outbound HTTPS connection: zero sockets to :443, while live connections to the
9
+ // Starlink dish and the local database stayed up, file descriptors at 50 of 524288, and
10
+ // a second node process in the SAME container reached the same host in 979 ms. It never
11
+ // recovered — 33 minutes the second time. Restarting bought about twenty minutes.
12
+ //
13
+ // Starlink is behind CGNAT, which drops an idle NAT mapping WITHOUT sending an RST. The
14
+ // pooled keep-alive socket then looks alive to the client and is dead on the wire.
15
+ // fetch() (undici) keeps such sockets, and a plugin has no supported way to reset that
16
+ // pool: `undici` is not requirable on the boat (Node bundles it internally),
17
+ // `Connection: close` is a forbidden header that fetch strips, and reaching for the
18
+ // global-dispatcher symbol is version-specific guesswork.
19
+ //
20
+ // Core http/https gives what is actually needed:
21
+ // - an agent we own, so a poisoned pool can be thrown away (resetTransport)
22
+ // - a short keep-alive, so an idle socket is dropped by US before CGNAT drops it
23
+ // - the REAL error code. fetch reports every transport failure as the uniformly
24
+ // useless string "fetch failed" and hides the reason in e.cause, so a wedged process
25
+ // and a boat genuinely at sea produced identical logs. Here a caller sees
26
+ // ECONNRESET / ETIMEDOUT / ENOTFOUND / ECONNREFUSED directly.
27
+ //
28
+ // ONE pool for the whole plugin, so one reset clears every subsystem at once — during
29
+ // the incident sync, the mirror, the manifest poller and the contract check were all
30
+ // wedged together, because they share the process, not because they share code.
31
+ //
32
+ // The response shape mirrors the parts of fetch() the callers actually used, so the call
33
+ // sites read the same: { ok, status, headers.get(), headers.forEach(), headers
34
+ // .getSetCookie(), buffer, text(), json() }. Transport failures THROW, as fetch does —
35
+ // with `.code` set, which fetch never gave us.
36
+
37
+ const http = require('http')
38
+ const https = require('https')
39
+ const { URL } = require('url')
40
+
41
+ // Short keep-alive: long enough that a steady stream reuses a socket, short enough that
42
+ // an idle one is closed by us well before a CGNAT mapping expires.
43
+ const AGENT_OPTS = { keepAlive: true, keepAliveMsecs: 5000, timeout: 15000, maxSockets: 8 }
44
+ const MAX_BODY_BYTES = 64 * 1024 * 1024 // Cesium.js is ~6 MB; this is a sanity ceiling
45
+
46
+ let agents = null
47
+ let generation = 0
48
+
49
+ function pool () {
50
+ if (!agents) agents = { http: new http.Agent(AGENT_OPTS), https: new https.Agent(AGENT_OPTS) }
51
+ return agents
52
+ }
53
+
54
+ // Throw the pools away. The next request builds fresh sockets — the only thing that
55
+ // recovers a pool whose sockets are dead but look open. destroy() closes idle sockets
56
+ // only; anything in flight finishes normally.
57
+ function resetTransport () {
58
+ if (agents) {
59
+ try { agents.http.destroy() } catch {}
60
+ try { agents.https.destroy() } catch {}
61
+ }
62
+ agents = null
63
+ return ++generation
64
+ }
65
+
66
+ function headersView (raw) {
67
+ const lower = {}
68
+ for (const [k, v] of Object.entries(raw || {})) lower[k.toLowerCase()] = v
69
+ return {
70
+ get (name) {
71
+ const v = lower[String(name).toLowerCase()]
72
+ return v == null ? null : (Array.isArray(v) ? v.join(', ') : String(v))
73
+ },
74
+ forEach (fn) {
75
+ for (const [k, v] of Object.entries(lower)) fn(Array.isArray(v) ? v.join(', ') : String(v), k)
76
+ },
77
+ // Node keeps set-cookie as an array already — the one header that must not be joined.
78
+ getSetCookie () {
79
+ const v = lower['set-cookie']
80
+ return v == null ? [] : (Array.isArray(v) ? v : [String(v)])
81
+ }
82
+ }
83
+ }
84
+
85
+ // fetch-shaped, minus the parts nothing here uses. Throws on transport failure with
86
+ // `.code` populated; an HTTP error status resolves normally (check `ok`/`status`).
87
+ function request (url, { method = 'GET', headers = {}, body = null, timeoutMs = 20000 } = {}) {
88
+ return new Promise((resolve, reject) => {
89
+ const settled = [] // run when the socket returns to the pool — see the note below
90
+ let u
91
+ try { u = new URL(url) } catch (e) { return reject(Object.assign(new Error(`bad url: ${url}`), { code: 'ERR_INVALID_URL' })) }
92
+ const lib = u.protocol === 'https:' ? https : http
93
+ const agent = u.protocol === 'https:' ? pool().https : pool().http
94
+
95
+ const hdrs = { ...headers }
96
+ if (body != null && hdrs['Content-Length'] == null && hdrs['content-length'] == null) {
97
+ hdrs['Content-Length'] = Buffer.byteLength(body)
98
+ }
99
+
100
+ const req = lib.request({
101
+ protocol: u.protocol,
102
+ hostname: u.hostname,
103
+ port: u.port || undefined,
104
+ path: u.pathname + u.search,
105
+ method,
106
+ agent,
107
+ headers: hdrs
108
+ }, (res) => {
109
+ const chunks = []
110
+ let size = 0
111
+ res.on('data', (c) => {
112
+ size += c.length
113
+ if (size > MAX_BODY_BYTES) { req.destroy(Object.assign(new Error('response too large'), { code: 'EMSGSIZE' })); return }
114
+ chunks.push(c)
115
+ })
116
+ res.on('error', (e) => { settled.forEach((f) => f()); reject(Object.assign(e, { code: e.code || 'ERR_STREAM' })) })
117
+ res.on('end', () => {
118
+ settled.forEach((f) => f())
119
+ const buffer = Buffer.concat(chunks)
120
+ resolve({
121
+ ok: res.statusCode >= 200 && res.statusCode < 300,
122
+ status: res.statusCode,
123
+ headers: headersView(res.headers),
124
+ buffer,
125
+ text: async () => buffer.toString('utf8'),
126
+ json: async () => JSON.parse(buffer.toString('utf8')),
127
+ arrayBuffer: async () => buffer
128
+ })
129
+ })
130
+ })
131
+
132
+ if (timeoutMs) {
133
+ req.setTimeout(timeoutMs, () => req.destroy(Object.assign(new Error(`timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT' })))
134
+ }
135
+ // An IDLE pooled socket must never be the reason the host process cannot exit — Signal
136
+ // K has to be able to shut down and a test runner has to finish. But an IN-FLIGHT one
137
+ // must hold the loop, or Node exits mid-request and the caller never resolves (which
138
+ // is exactly what a first attempt at this did). So: ref while the request is running,
139
+ // unref once it is back in the pool.
140
+ let sock = null
141
+ const idle = () => { try { if (sock && sock.unref) sock.unref() } catch {} }
142
+ req.on('socket', (s) => { sock = s; try { if (s.ref) s.ref() } catch {} })
143
+ settled.push(idle)
144
+ req.on('error', (e) => { settled.forEach((f) => f()); reject(Object.assign(e, { code: e.code || 'ERR_REQUEST' })) })
145
+ req.end(body == null ? undefined : body)
146
+ })
147
+ }
148
+
149
+ module.exports = { request, resetTransport, _agents: () => agents, _generation: () => generation }
@@ -14,6 +14,7 @@
14
14
 
15
15
  const fs = require('fs')
16
16
  const fsp = fs.promises
17
+ const { request } = require('../net') // owned connection pool + real error codes; see lib/net.js
17
18
  const path = require('path')
18
19
  const crypto = require('crypto')
19
20
 
@@ -47,7 +48,7 @@ async function readFromDisk (file, meta) {
47
48
  async function fetchAndStore (url, file, meta, timeoutMs) {
48
49
  let resp
49
50
  try {
50
- resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
51
+ resp = await request(url, { timeoutMs })
51
52
  } catch (e) {
52
53
  const err = new Error(`offline / unreachable: ${e.message}`); err.offline = true; throw err
53
54
  }
@@ -8,6 +8,7 @@ const { createManifest } = require('./manifest')
8
8
  const { createContractCheck } = require('../telemetry/contract')
9
9
  const { createSeeder } = require('./seed')
10
10
  const { countBboxTiles, bboxTiles, boxAround } = require('./tiles')
11
+ const { request } = require('../net')
11
12
 
12
13
  // Caching proxy module. The standalone server (origin root, `proxyPort`) is what
13
14
  // the browser points at. It routes by path:
@@ -181,7 +182,7 @@ function createProxy (app, options) {
181
182
  if (zoomCache) return zoomCache
182
183
  const out = { ...FALLBACK_MAX_ZOOM }
183
184
  try {
184
- const r = await fetch(cfg.upstream + '/api/assets', { signal: AbortSignal.timeout(cfg.timeoutMs) })
185
+ const r = await request(cfg.upstream + '/api/assets', { timeoutMs: cfg.timeoutMs })
185
186
  if (r.ok) {
186
187
  const j = await r.json()
187
188
  const raster = j && j.tiles && j.tiles.manifest && j.tiles.manifest.maxZoom
@@ -338,7 +339,7 @@ function createProxy (app, options) {
338
339
  }
339
340
  let r
340
341
  try {
341
- r = await fetch(target, { method: req.method, headers: fwd, body: chunks.length ? Buffer.concat(chunks) : undefined })
342
+ r = await request(target, { method: req.method, headers: fwd, body: chunks.length ? Buffer.concat(chunks) : undefined, timeoutMs: cfg.timeoutMs })
342
343
  } catch (e) { res.statusCode = 502; res.end('upstream unreachable'); return }
343
344
  res.statusCode = r.status
344
345
  r.headers.forEach((v, k) => {
@@ -18,6 +18,7 @@
18
18
  const fs = require('fs')
19
19
  const fsp = fs.promises
20
20
  const path = require('path')
21
+ const { request } = require('../net')
21
22
 
22
23
  function createManifest (app, options) {
23
24
  const log = (m) => (app.debug ? app.debug('[manifest] ' + m) : console.log('[sailkick-boat:manifest]', m))
@@ -61,7 +62,7 @@ function createManifest (app, options) {
61
62
  if (!cfg) return
62
63
  let m
63
64
  try {
64
- const resp = await fetch(cfg.upstream + cfg.path, { signal: AbortSignal.timeout(cfg.timeoutMs) })
65
+ const resp = await request(cfg.upstream + cfg.path, { timeoutMs: cfg.timeoutMs })
65
66
  if (!resp.ok) return
66
67
  m = await resp.json()
67
68
  } catch { return } // offline / bad JSON → no-op, nothing invalidated
package/lib/sync/index.js CHANGED
@@ -8,9 +8,15 @@
8
8
  const fs = require('fs')
9
9
  const path = require('path')
10
10
  const { Spool, DEFAULT_MAX_BYTES } = require('./spool')
11
- const { writeLines } = require('./influxWrite')
11
+ const { writeLines, resetTransport } = require('./influxWrite')
12
12
  const { deltaToLines } = require('./lineprotocol')
13
13
 
14
+ // Rebuild the connection pool after this many consecutive TRANSPORT failures. Low enough
15
+ // that a wedged process recovers in seconds instead of needing a restart, high enough
16
+ // that an ordinary offline stretch does not churn agents.
17
+ const RESET_AFTER_FAILURES = 5
18
+ const WARN_REPEAT_MS = 300000 // re-state an ongoing outage every 5 min, not every retry
19
+
14
20
  function createSync (app, options) {
15
21
  const log = (m) => (app.debug ? app.debug('[sync] ' + m) : console.log('[sailkick-boat:sync]', m))
16
22
  // Anything that stops telemetry reaching the cloud goes to `warn`, which lands in the
@@ -18,7 +24,6 @@ function createSync (app, options) {
18
24
  // an outage must never be visible ONLY in the status line, which is what made a
19
25
  // day-long silent failure possible.
20
26
  const warn = (m) => (app.error ? app.error('[sailkick-boat:sync] ' + m) : console.error('[sailkick-boat:sync]', m))
21
- const WARN_REPEAT_MS = 300000 // re-state an ongoing outage every 5 min, not every retry
22
27
  let state = null
23
28
 
24
29
  function start () {
@@ -55,11 +60,26 @@ function createSync (app, options) {
55
60
  // never reach its endpoint used to produce no log output at all.
56
61
  const noteFailure = (res) => {
57
62
  state.failCount++
63
+ // A poisoned connection pool cannot heal itself: the sockets look open and are dead
64
+ // on the wire (Starlink is behind CGNAT, which drops idle mappings without an RST),
65
+ // so every retry writes into the same corpse. Twice this left the process unable to
66
+ // open ANY outbound HTTPS for over half an hour while a second process in the same
67
+ // container reached the host in under a second. Throwing the pool away is the one
68
+ // thing that recovers it. Only for TRANSPORT failures — an HTTP status means the
69
+ // connection worked fine.
70
+ if (res && res.networkError && state.failCount % RESET_AFTER_FAILURES === 0) {
71
+ const gen = resetTransport()
72
+ warn(`${state.failCount} consecutive transport failures — rebuilt the connection pool (generation ${gen}); if the link is up this recovers on the next attempt`)
73
+ }
58
74
  const now = Date.now()
59
75
  if (state.failing && now - state.lastWarnAt < WARN_REPEAT_MS) return
60
76
  state.failing = true
61
77
  state.lastWarnAt = now
62
- const why = res && res.status ? `HTTP ${res.status}` : `unreachable${res && res.error ? ` (${res.error})` : ''}`
78
+ // Name the REASON. "fetch failed" told us nothing through two incidents; ECONNRESET
79
+ // and ETIMEDOUT are different problems with different fixes.
80
+ const why = res && res.status
81
+ ? `HTTP ${res.status}`
82
+ : `unreachable${res && (res.code || res.error) ? ` (${res.code || res.error})` : ''}`
63
83
  warn(`cannot write to ${cfg.influxUrl} — ${why}; ${state.failCount} failed attempt(s), telemetry is buffering on disk`)
64
84
  }
65
85
  const noteSuccess = () => {
@@ -4,10 +4,10 @@
4
4
  //
5
5
  // Return shape:
6
6
  // { ok: true, status: 204 }
7
- // { ok: false, retryable: true, networkError: true } connection failed
8
- // { ok: false, retryable: true, status } 429 / 5xx (transient)
9
- // { ok: false, configError: true, status, body } 404 / 401 / 403
10
- // { ok: false, retryable: false, status, body } other 4xx (bad data)
7
+ // { ok: false, retryable: true, networkError: true, code, error } connection failed
8
+ // { ok: false, retryable: true, status } 429 / 5xx (transient)
9
+ // { ok: false, configError: true, status, body } 404 / 401 / 403
10
+ // { ok: false, retryable: false, status, body } other 4xx (bad data)
11
11
  //
12
12
  // Most 4xx is non-retryable: retrying a malformed batch forever would wedge the queue,
13
13
  // so the caller quarantines it instead.
@@ -21,7 +21,11 @@
21
21
  // and keeps retrying slowly, so correcting the config recovers on its own.
22
22
  const CONFIG_ERROR_STATUS = new Set([401, 403, 404])
23
23
 
24
+ // The transport (an owned connection pool, real error codes) lives in lib/net.js —
25
+ // see the long note there on why fetch() is unusable on a CGNAT satellite link.
26
+
24
27
  const zlib = require('zlib')
28
+ const { request, resetTransport } = require('../net')
25
29
 
26
30
  async function writeLines (cfg, body) {
27
31
  const base = cfg.influxUrl.replace(/\/+$/, '')
@@ -31,10 +35,9 @@ async function writeLines (cfg, body) {
31
35
  '&precision=ns'
32
36
 
33
37
  const gz = zlib.gzipSync(Buffer.from(body, 'utf8'))
34
-
35
- let res
38
+ let r
36
39
  try {
37
- res = await fetch(url, {
40
+ r = await request(url, {
38
41
  method: 'POST',
39
42
  headers: {
40
43
  Authorization: `Token ${cfg.token}`,
@@ -42,21 +45,20 @@ async function writeLines (cfg, body) {
42
45
  'Content-Encoding': 'gzip'
43
46
  },
44
47
  body: gz,
45
- signal: cfg.timeoutMs ? AbortSignal.timeout(cfg.timeoutMs) : undefined
48
+ timeoutMs: cfg.timeoutMs
46
49
  })
47
50
  } catch (e) {
48
- return { ok: false, retryable: true, networkError: true, error: e.message }
51
+ // The REAL code, not fetch's "fetch failed" see lib/net.js.
52
+ return { ok: false, retryable: true, networkError: true, code: e.code || null, error: e.message }
49
53
  }
54
+ const status = r.status
50
55
 
51
- if (res.status === 204) return { ok: true, status: 204 }
52
-
53
- let text = ''
54
- try { text = await res.text() } catch {}
55
- if (CONFIG_ERROR_STATUS.has(res.status)) {
56
- return { ok: false, configError: true, retryable: false, status: res.status, body: text }
56
+ if (status === 204) return { ok: true, status: 204 }
57
+ if (CONFIG_ERROR_STATUS.has(status)) {
58
+ return { ok: false, configError: true, retryable: false, status, body: await r.text() }
57
59
  }
58
- const retryable = res.status === 429 || res.status >= 500
59
- return { ok: false, retryable, status: res.status, body: text }
60
+ const retryable = status === 429 || status >= 500
61
+ return { ok: false, retryable, status, body: await r.text() }
60
62
  }
61
63
 
62
- module.exports = { writeLines }
64
+ module.exports = { writeLines, resetTransport }
@@ -22,6 +22,8 @@
22
22
  // sha256sum <sailkick>/public/engine/signalk-map.js | cut -c1-12
23
23
 
24
24
  // sha256(app public/engine/signalk-map.js)[0..12] as ported in v0.18.6
25
+ const { request } = require('../net') // owned connection pool + real error codes
26
+
25
27
  const PINNED_APP_HASH = '2015ae986cd8'
26
28
 
27
29
  function createContractCheck (app, options = {}) {
@@ -37,7 +39,7 @@ function createContractCheck (app, options = {}) {
37
39
  if (!upstream) return drifted
38
40
  let remote
39
41
  try {
40
- const r = await fetch(String(upstream).replace(/\/+$/, '') + '/health', { signal: AbortSignal.timeout(timeoutMs) })
42
+ const r = await request(String(upstream).replace(/\/+$/, '') + '/health', { timeoutMs })
41
43
  if (!r.ok) return drifted
42
44
  const body = await r.json()
43
45
  remote = body && body.contracts && body.contracts.signalkMap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.23.0",
3
+ "version": "0.23.3",
4
4
  "description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {