sailkick-boat 0.23.8 → 0.23.9

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 (3) hide show
  1. package/README.md +7 -0
  2. package/lib/net.js +50 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -52,6 +52,13 @@ 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
+ - **Responses are decoded and checked for completeness.** Core `https` does neither, and
56
+ `fetch` did both silently: the upstream serves terrain tiles pre-compressed with
57
+ `Content-Encoding: gzip` whether asked to or not, so for one release the mirror cached
58
+ gzip bytes labelled as terrain and Cesium read the gzip header as a vertex count
59
+ ("Invalid typed array length: 11239580910"). A body shorter than its `Content-Length` is
60
+ now rejected too — tiles are pinned once written, so a truncated one would be served for
61
+ ever.
55
62
  - **All cloud traffic uses core `https`, not `fetch`** — telemetry sync, the offline
56
63
  mirror, the cache-manifest poller, the contract check, the backfill and the Sync page
57
64
  share one connection pool (`lib/net.js`), so one reset clears every subsystem. Twice in one afternoon this boat's
package/lib/net.js CHANGED
@@ -33,8 +33,25 @@
33
33
  // sites read the same: { ok, status, headers.get(), headers.forEach(), headers
34
34
  // .getSetCookie(), buffer, text(), json() }. Transport failures THROW, as fetch does —
35
35
  // with `.code` set, which fetch never gave us.
36
+ //
37
+ // TWO THINGS fetch() DID FOR FREE that core http does NOT, both of which have to be done
38
+ // here or the bytes are silently wrong:
39
+ //
40
+ // 1. CONTENT-ENCODING. The upstream serves terrain tiles pre-compressed and sets
41
+ // Content-Encoding: gzip whether or not it was asked to. fetch decompressed
42
+ // transparently; core http hands back the gzip stream. The mirror cached those bytes
43
+ // as terrain, and Cesium read the gzip header as a vertex count: "Invalid typed array
44
+ // length: 11239580910". Responses are decoded here, and the encoding header is
45
+ // dropped from the exposed set, exactly as fetch does — the body is no longer encoded,
46
+ // so advertising that it is would be a lie the cache then stores.
47
+ //
48
+ // 2. COMPLETENESS. fetch rejects a body shorter than its Content-Length. Core http emits
49
+ // 'end' regardless and leaves `res.complete` false, so a connection dropped mid-body
50
+ // would look like a clean short response — and tiles are PINNED once written, so a
51
+ // truncated one would be served for ever. Checked explicitly.
36
52
 
37
53
  const http = require('http')
54
+ const zlib = require('zlib')
38
55
  const https = require('https')
39
56
  const { URL } = require('url')
40
57
 
@@ -63,6 +80,17 @@ function resetTransport () {
63
80
  return ++generation
64
81
  }
65
82
 
83
+ // Undo Content-Encoding, as fetch does. Anything unrecognised is passed through
84
+ // untouched rather than guessed at.
85
+ function decode (buf, encoding, cb) {
86
+ const enc = String(encoding || '').trim().toLowerCase()
87
+ if (!enc || enc === 'identity' || buf.length === 0) return cb(null, buf)
88
+ if (enc === 'gzip' || enc === 'x-gzip') return zlib.gunzip(buf, cb)
89
+ if (enc === 'deflate') return zlib.inflate(buf, (e, out) => (e ? zlib.inflateRaw(buf, cb) : cb(null, out)))
90
+ if (enc === 'br') return zlib.brotliDecompress(buf, cb)
91
+ cb(null, buf)
92
+ }
93
+
66
94
  function headersView (raw) {
67
95
  const lower = {}
68
96
  for (const [k, v] of Object.entries(raw || {})) lower[k.toLowerCase()] = v
@@ -92,7 +120,7 @@ function request (url, { method = 'GET', headers = {}, body = null, timeoutMs =
92
120
  const lib = u.protocol === 'https:' ? https : http
93
121
  const agent = u.protocol === 'https:' ? pool().https : pool().http
94
122
 
95
- const hdrs = { ...headers }
123
+ const hdrs = { 'Accept-Encoding': 'gzip, deflate, br', ...headers }
96
124
  if (body != null && hdrs['Content-Length'] == null && hdrs['content-length'] == null) {
97
125
  hdrs['Content-Length'] = Buffer.byteLength(body)
98
126
  }
@@ -116,15 +144,27 @@ function request (url, { method = 'GET', headers = {}, body = null, timeoutMs =
116
144
  res.on('error', (e) => { settled.forEach((f) => f()); reject(Object.assign(e, { code: e.code || 'ERR_STREAM' })) })
117
145
  res.on('end', () => {
118
146
  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
147
+ // A body cut short must never be mistaken for a short body — see note (2) above.
148
+ if (res.complete === false) {
149
+ return reject(Object.assign(new Error('response truncated'), { code: 'ECONNRESET' }))
150
+ }
151
+ const raw = Buffer.concat(chunks)
152
+ decode(raw, res.headers['content-encoding'], (err, buffer) => {
153
+ if (err) return reject(Object.assign(err, { code: err.code || 'ERR_CONTENT_DECODING' }))
154
+ // Drop the encoding header: the body is decoded now, and a cache that stored it
155
+ // would replay a false claim to every future reader.
156
+ const headers = { ...res.headers }
157
+ delete headers['content-encoding']
158
+ delete headers['content-length'] // no longer describes the body we return
159
+ resolve({
160
+ ok: res.statusCode >= 200 && res.statusCode < 300,
161
+ status: res.statusCode,
162
+ headers: headersView(headers),
163
+ buffer,
164
+ text: async () => buffer.toString('utf8'),
165
+ json: async () => JSON.parse(buffer.toString('utf8')),
166
+ arrayBuffer: async () => buffer
167
+ })
128
168
  })
129
169
  })
130
170
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.23.8",
3
+ "version": "0.23.9",
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": {