sailkick-boat 0.22.1 → 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.
Files changed (2) hide show
  1. package/lib/cloud/index.js +50 -28
  2. package/package.json +1 -1
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.22.1",
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": {