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.
- package/lib/cloud/index.js +66 -28
- package/lib/profile/index.js +19 -3
- package/package.json +1 -1
package/lib/cloud/index.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
})
|
|
93
|
-
|
|
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
|
|
99
|
-
if (!
|
|
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:
|
|
126
|
+
session = { cookie: cookie.cookie, slug: name, expiresAt: cookie.expiresAt }
|
|
104
127
|
await persist()
|
|
105
|
-
log(`logged in to ${upstream} as ${name}${
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 = []
|
package/lib/profile/index.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
109
|
+
if (size > MAX_BODY_BYTES) { req.destroy(); resolve(undefined); return }
|
|
94
110
|
chunks.push(c)
|
|
95
111
|
})
|
|
96
|
-
req.on('error',
|
|
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.
|
|
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": {
|