sailkick-boat 0.22.0 → 0.22.2

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.
@@ -80,29 +80,52 @@ function createCloud (app, options = {}) {
80
80
  return null
81
81
  }
82
82
 
83
+ // Node wraps every transport failure as the useless message "fetch failed" and hides
84
+ // the real reason in e.cause — ENOTFOUND, ECONNRESET, ETIMEDOUT and a TLS failure are
85
+ // very different problems and want different fixes, so say which.
86
+ const why = (e) => {
87
+ const c = e && e.cause
88
+ const code = c && (c.code || c.message)
89
+ return code ? `${e.message}: ${code}` : (e ? e.message : 'unknown')
90
+ }
91
+
92
+ // Retry TRANSPORT failures only — never an HTTP status, so a 401 stays a 401. This
93
+ // boat's link drops for a second or two at a time (live sync recovers after a single
94
+ // attempt), and a button that dead-ends on the first blip is needlessly fragile.
95
+ async function attempt (fn, tries = 3) {
96
+ let last
97
+ for (let i = 0; i < tries; i++) {
98
+ try { return { ok: true, value: await fn() } } catch (e) {
99
+ last = e
100
+ if (i < tries - 1) await new Promise((resolve) => setTimeout(resolve, 400 * Math.pow(3, i)))
101
+ }
102
+ }
103
+ return { ok: false, error: last }
104
+ }
105
+
83
106
  async function login (slug, password) {
84
107
  if (!upstream) return { ok: false, code: 'no-upstream', message: 'no cloud host configured' }
85
- let r
86
- try {
87
- r = await fetch(`${upstream}/api/auth/login`, {
88
- method: 'POST',
89
- headers: { 'Content-Type': 'application/json' },
90
- body: JSON.stringify({ slug, password }),
91
- signal: AbortSignal.timeout(timeoutMs)
92
- })
93
- } catch (e) {
94
- return { ok: false, code: 'offline', message: `cannot reach ${upstream} (${e.message})` }
108
+ const got = await attempt(() => fetch(`${upstream}/api/auth/login`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ slug, password }),
112
+ signal: AbortSignal.timeout(timeoutMs)
113
+ }))
114
+ if (!got.ok) {
115
+ warn(`login could not reach ${upstream} after 3 attempts — ${why(got.error)}`)
116
+ return { ok: false, code: 'offline', message: `cannot reach ${upstream} ${why(got.error)}. The boat's link may have dropped; try again.` }
95
117
  }
118
+ const r = got.value
96
119
  if (r.status === 401) return { ok: false, code: 'bad-credentials', message: 'wrong boat name or password' }
97
120
  if (!r.ok) return { ok: false, code: 'login-failed', message: `the cloud replied HTTP ${r.status}` }
98
- const got = readCookie(r.headers)
99
- if (!got) return { ok: false, code: 'no-session', message: 'the cloud accepted the login but sent no session' }
121
+ const cookie = readCookie(r.headers)
122
+ if (!cookie) return { ok: false, code: 'no-session', message: 'the cloud accepted the login but sent no session' }
100
123
 
101
124
  let name = slug
102
125
  try { const j = await r.json(); name = (j && j.boat && (j.boat.slug || j.boat.name)) || slug } catch {}
103
- session = { cookie: got.cookie, slug: name, expiresAt: got.expiresAt }
126
+ session = { cookie: cookie.cookie, slug: name, expiresAt: cookie.expiresAt }
104
127
  await persist()
105
- log(`logged in to ${upstream} as ${name}${got.expiresAt ? `; session valid until ${new Date(got.expiresAt).toISOString().slice(0, 10)}` : ''}`)
128
+ log(`logged in to ${upstream} as ${name}${cookie.expiresAt ? `; session valid until ${new Date(cookie.expiresAt).toISOString().slice(0, 10)}` : ''}`)
106
129
  return { ok: true, ...status() }
107
130
  }
108
131
 
@@ -128,21 +151,20 @@ function createCloud (app, options = {}) {
128
151
  // handler and a boat is offline most of the time.
129
152
  async function request (apiPath, { method = 'GET', body = null } = {}) {
130
153
  if (!session) return { ok: false, status: 401, code: 'logged-out', message: 'not logged in to the cloud' }
131
- let r
132
- try {
133
- r = await fetch(upstream + apiPath, {
134
- method,
135
- headers: {
136
- Cookie: session.cookie,
137
- Accept: 'application/json',
138
- ...(body ? { 'Content-Type': 'application/json' } : {})
139
- },
140
- body: body || undefined,
141
- signal: AbortSignal.timeout(timeoutMs)
142
- })
143
- } catch (e) {
144
- return { ok: false, status: 0, code: 'offline', message: `cannot reach the cloud (${e.message})` }
154
+ const got = await attempt(() => fetch(upstream + apiPath, {
155
+ method,
156
+ headers: {
157
+ Cookie: session.cookie,
158
+ Accept: 'application/json',
159
+ ...(body ? { 'Content-Type': 'application/json' } : {})
160
+ },
161
+ body: body || undefined,
162
+ signal: AbortSignal.timeout(timeoutMs)
163
+ }))
164
+ if (!got.ok) {
165
+ return { ok: false, status: 0, code: 'offline', message: `cannot reach the cloud — ${why(got.error)}. The boat's link may have dropped; try again.` }
145
166
  }
167
+ const r = got.value
146
168
  // A 401 means the session died (expired, or revoked by a logout elsewhere). Clear it
147
169
  // so the page shows a login form instead of silently failing every button.
148
170
  if (r.status === 401) {
@@ -164,7 +186,23 @@ function createCloud (app, options = {}) {
164
186
  res.end(JSON.stringify(obj))
165
187
  }
166
188
 
189
+ // Signal K mounts plugin routers AFTER its own bodyParser.json(), so on that path the
190
+ // body is already parsed and the request stream is spent — listening for 'data'/'end'
191
+ // waits for events that will never fire, and the request hangs for ever with no error.
192
+ // (That is exactly what happened: the Sync page sat on "Signing in…".) On the mirror
193
+ // there is no body parser and the stream is live, so both paths must be handled.
167
194
  function readJson (req) {
195
+ if (req.body !== undefined && req.body !== null) {
196
+ if (typeof req.body === 'string') {
197
+ if (!req.body.trim()) return Promise.resolve(null)
198
+ try { return Promise.resolve(JSON.parse(req.body)) } catch { return Promise.resolve(undefined) }
199
+ }
200
+ if (typeof req.body === 'object') {
201
+ // bodyParser.json() gives {} for an absent body; treat that as "nothing sent".
202
+ return Promise.resolve(Object.keys(req.body).length || Array.isArray(req.body) ? req.body : null)
203
+ }
204
+ }
205
+ if (req.readableEnded || req.complete) return Promise.resolve(null) // stream already drained
168
206
  return new Promise((resolve) => {
169
207
  let size = 0
170
208
  const chunks = []
@@ -84,16 +84,32 @@ function createProfile (app, options = {}) {
84
84
  const badBody = (res, what) => send(res, 400, { ok: false, code: 'bad-body', message: `${what} must be a JSON object` })
85
85
  const failed = (res, e) => { lastError = e.message; warn(e.message); send(res, 500, { ok: false, code: 'profile-error', message: e.message }) }
86
86
 
87
+ // Signal K mounts plugin routers AFTER its own bodyParser.json(), so on that path the
88
+ // body is already parsed and the request stream is spent — listening for 'data'/'end'
89
+ // waits for events that will never fire, and the request hangs for ever with no error.
90
+ // (That is exactly what happened: the Sync page sat on "Signing in…".) On the mirror
91
+ // there is no body parser and the stream is live, so both paths must be handled.
87
92
  function readJson (req) {
88
- return new Promise((resolve, reject) => {
93
+ if (req.body !== undefined && req.body !== null) {
94
+ if (typeof req.body === 'string') {
95
+ if (!req.body.trim()) return Promise.resolve(null)
96
+ try { return Promise.resolve(JSON.parse(req.body)) } catch { return Promise.resolve(undefined) }
97
+ }
98
+ if (typeof req.body === 'object') {
99
+ // bodyParser.json() gives {} for an absent body; treat that as "nothing sent".
100
+ return Promise.resolve(Object.keys(req.body).length || Array.isArray(req.body) ? req.body : null)
101
+ }
102
+ }
103
+ if (req.readableEnded || req.complete) return Promise.resolve(null) // stream already drained
104
+ return new Promise((resolve) => {
89
105
  let size = 0
90
106
  const chunks = []
91
107
  req.on('data', (c) => {
92
108
  size += c.length
93
- if (size > MAX_BODY_BYTES) { reject(new Error('body too large')); req.destroy(); return }
109
+ if (size > MAX_BODY_BYTES) { req.destroy(); resolve(undefined); return }
94
110
  chunks.push(c)
95
111
  })
96
- req.on('error', reject)
112
+ req.on('error', () => resolve(undefined))
97
113
  req.on('end', () => {
98
114
  const raw = Buffer.concat(chunks).toString('utf8')
99
115
  if (!raw.trim()) return resolve(null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
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": {