@reddb-io/sdk 1.23.4 → 1.24.0
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 +29 -22
- package/index.d.ts +10 -6
- package/package.json +13 -5
- package/src/binary.js +23 -26
- package/src/cache.js +4 -8
- package/src/http.js +6 -6
- package/src/index.js +10 -0
- package/src/native-engine.js +120 -0
- package/src/platform-binary.js +62 -0
- package/src/vault.js +5 -1
- package/postinstall.js +0 -146
- package/src/internal/asset-fetcher/asset-name.js +0 -37
- package/src/internal/asset-fetcher/checksum.js +0 -23
- package/src/internal/asset-fetcher/download.js +0 -170
- package/src/internal/asset-fetcher/index.js +0 -130
- package/src/internal/bin-resolver/index.js +0 -57
- package/src/internal/version-compare/index.js +0 -163
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @reddb-io/sdk
|
|
2
2
|
|
|
3
3
|
Official RedDB SDK for JavaScript and TypeScript. Speaks JSON-RPC 2.0 over
|
|
4
|
-
stdio to a local `red` binary, which
|
|
4
|
+
stdio to a local `red` binary, which npm installs for your platform automatically
|
|
5
|
+
(no install script, no download).
|
|
5
6
|
Works in **Node 18+**, **Bun** and **Deno** (via `npm:` specifier) — same
|
|
6
7
|
package, no per-runtime fork.
|
|
7
8
|
|
|
@@ -30,24 +31,34 @@ In Deno:
|
|
|
30
31
|
import { connect } from 'npm:@reddb-io/sdk'
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
The `
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
The `red` binary ships as a per-platform package
|
|
35
|
+
(`@reddb-io/red-linux-x64`, `-linux-x64-musl`, `-linux-arm64`,
|
|
36
|
+
`-linux-arm64-musl`, `-linux-arm`, `-darwin-x64`, `-darwin-arm64`,
|
|
37
|
+
`-win32-x64`) listed as an `optionalDependency` of this package. Your package
|
|
38
|
+
manager installs only the one matching your OS, CPU and libc, so there is **no
|
|
39
|
+
install script and no download**: it works with `--ignore-scripts`, behind a
|
|
40
|
+
registry mirror, and offline once the packages are cached. Don't install with
|
|
41
|
+
`--omit=optional` / `--no-optional`, or the binary is skipped.
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
### In-process engine
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
On platforms whose package includes `reddb.node`, `connect('memory://')` and
|
|
46
|
+
`connect('file://...')` run the engine **inside your process** — no subprocess,
|
|
47
|
+
same API and transaction semantics. Elsewhere (and when you pass
|
|
48
|
+
`options.binary`) the SDK spawns `red rpc --stdio` as before. Set
|
|
49
|
+
`REDDB_NATIVE_ADDON=/path/to/reddb.node` to point at a specific addon.
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
Because the engine shares your process, an engine panic aborts the process (the
|
|
52
|
+
engine's crash-safety model relies on WAL recovery, not unwinding). A file-backed
|
|
53
|
+
database allows one writer: a second `connect('file://...')` on the same file
|
|
54
|
+
fails until the first is closed.
|
|
55
|
+
|
|
56
|
+
## Using your own binary
|
|
49
57
|
|
|
50
|
-
|
|
58
|
+
To use a `red` you provide (a custom build, an unsupported platform), point
|
|
59
|
+
the driver at it:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
51
62
|
export REDDB_BIN=/path/to/red # canonical, per ADR 0006
|
|
52
63
|
# (REDDB_BINARY_PATH is a deprecated alias kept for the rollout window.)
|
|
53
64
|
```
|
|
@@ -64,15 +75,11 @@ Three ways to get a `red` binary:
|
|
|
64
75
|
cargo build --release --bin red
|
|
65
76
|
export REDDB_BIN="$PWD/target/release/red"
|
|
66
77
|
```
|
|
67
|
-
- Download a prebuilt asset from the releases page
|
|
68
|
-
`<package>/bin/red[.exe]`:
|
|
78
|
+
- Download a prebuilt asset from the releases page:
|
|
69
79
|
<https://github.com/reddb-io/reddb/releases>
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
install surfaces the problem immediately. If you forget to provide the
|
|
74
|
-
binary, `connect()` raises a clear `binary "red" not found` error that names
|
|
75
|
-
`REDDB_BIN` as the override.
|
|
81
|
+
If no binary can be found, `connect()` raises a clear `binary "red" not found`
|
|
82
|
+
error that names the expected package and `REDDB_BIN` as the override.
|
|
76
83
|
|
|
77
84
|
## Quickstart
|
|
78
85
|
|
package/index.d.ts
CHANGED
|
@@ -167,15 +167,18 @@ export class RedDBError extends Error {
|
|
|
167
167
|
|
|
168
168
|
/** Options for cache.put(). Maps to Rust BlobCachePolicy fields. */
|
|
169
169
|
export interface CachePutOptions {
|
|
170
|
-
/**
|
|
170
|
+
/** Hard TTL in milliseconds. Omit for no time-based expiry. */
|
|
171
171
|
ttl_ms?: number
|
|
172
172
|
/** Tags for group invalidation via cache.invalidateTags(). */
|
|
173
173
|
tags?: string[]
|
|
174
|
-
/**
|
|
174
|
+
/** Native hard-expiry and admission policy for remote HTTP cache entries. */
|
|
175
175
|
policy?: {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
ttl_ms?: number
|
|
177
|
+
expires_at_unix_ms?: number
|
|
178
|
+
max_blob_bytes?: number
|
|
179
|
+
l1_admission?: 'auto' | 'always' | 'never'
|
|
180
|
+
priority?: number
|
|
181
|
+
version?: number
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -215,7 +218,7 @@ export class CacheClient {
|
|
|
215
218
|
invalidatePrefix(namespace: string, prefix: string): Promise<number>
|
|
216
219
|
/** Remove all entries tagged with any of the given tags. Returns count removed. */
|
|
217
220
|
invalidateTags(namespace: string, tags: string[]): Promise<number>
|
|
218
|
-
/** Remove
|
|
221
|
+
/** Remove entries through the caller-scoped POST /cache/ns/:namespace/flush endpoint. */
|
|
219
222
|
flushNamespace(namespace: string): Promise<void>
|
|
220
223
|
}
|
|
221
224
|
|
|
@@ -360,6 +363,7 @@ export class VaultClient {
|
|
|
360
363
|
options?: { collection?: string; tags?: string[] },
|
|
361
364
|
): Promise<QueryResult>
|
|
362
365
|
get(key: string, options?: { collection?: string }): Promise<QueryResult>
|
|
366
|
+
reveal(key: string, options?: { collection?: string }): Promise<QueryResult>
|
|
363
367
|
unseal(key: string, options?: { collection?: string }): Promise<QueryResult>
|
|
364
368
|
}
|
|
365
369
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/sdk",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
|
|
3
|
+
"version": "1.24.0",
|
|
4
|
+
"description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. The binary arrives as a per-platform optionalDependency — no install script. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"src/",
|
|
16
16
|
"index.d.ts",
|
|
17
|
-
"postinstall.js",
|
|
18
17
|
"README.md",
|
|
19
18
|
"LICENSE"
|
|
20
19
|
],
|
|
@@ -43,8 +42,17 @@
|
|
|
43
42
|
"bugs": {
|
|
44
43
|
"url": "https://github.com/reddb-io/reddb/issues"
|
|
45
44
|
},
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"@reddb-io/red-linux-x64": "1.24.0",
|
|
47
|
+
"@reddb-io/red-linux-x64-musl": "1.24.0",
|
|
48
|
+
"@reddb-io/red-linux-arm64": "1.24.0",
|
|
49
|
+
"@reddb-io/red-linux-arm64-musl": "1.24.0",
|
|
50
|
+
"@reddb-io/red-linux-arm": "1.24.0",
|
|
51
|
+
"@reddb-io/red-darwin-x64": "1.24.0",
|
|
52
|
+
"@reddb-io/red-darwin-arm64": "1.24.0",
|
|
53
|
+
"@reddb-io/red-win32-x64": "1.24.0"
|
|
54
|
+
},
|
|
46
55
|
"scripts": {
|
|
47
|
-
"
|
|
48
|
-
"test": "node --test test/ask.test.mjs test/cache.test.mjs test/db-helpers.test.mjs test/embedded-only.test.mjs test/helpers.test.mjs test/insert-ids.test.mjs test/kv.test.mjs test/params.test.mjs test/postinstall.test.mjs test/queue.test.mjs test/redwire.params.test.mjs test/transaction.test.mjs test/url.test.mjs && node test/smoke.test.mjs && node test/conformance.test.mjs && node test/readme-examples.test.mjs"
|
|
56
|
+
"test": "node --test test/ask.test.mjs test/cache.test.mjs test/db-helpers.test.mjs test/embedded-only.test.mjs test/helpers.test.mjs test/insert-ids.test.mjs test/kv.test.mjs test/params.test.mjs test/platform-binary.test.mjs test/native-engine.test.mjs test/queue.test.mjs test/redwire.params.test.mjs test/transaction.test.mjs test/url.test.mjs && node test/smoke.test.mjs && node test/conformance.test.mjs && node test/readme-examples.test.mjs"
|
|
49
57
|
}
|
|
50
58
|
}
|
package/src/binary.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* SDK lookup (`resolveSdkBinary`):
|
|
5
5
|
* 1. `REDDB_BIN` env var (the canonical override per ADR 0006).
|
|
6
6
|
* 2. `REDDB_BINARY_PATH` env var (legacy alias, deprecation window).
|
|
7
|
-
* 3.
|
|
7
|
+
* 3. The `@reddb-io/red-<platform>` package npm installed for this host
|
|
8
|
+
* (see `platform-binary.js`).
|
|
8
9
|
* 4. Otherwise throw an actionable error.
|
|
9
10
|
*
|
|
10
11
|
* PATH is **never** consulted. The wire-format coupling between the
|
|
@@ -13,19 +14,12 @@
|
|
|
13
14
|
*
|
|
14
15
|
* CLI lookup (`resolveCliBinary`):
|
|
15
16
|
* 1. `REDDB_BIN` env var.
|
|
16
|
-
* 2.
|
|
17
|
+
* 2. The `@reddb-io/red-<platform>` package.
|
|
17
18
|
* 3. PATH-resolved bare `red[.exe]` — appropriate for the CLI which
|
|
18
19
|
* *targets* PATH.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
|
-
import {
|
|
22
|
-
import { fileURLToPath } from 'node:url'
|
|
23
|
-
import { dirname, resolve, join } from 'node:path'
|
|
24
|
-
|
|
25
|
-
import { resolveBin } from './internal/bin-resolver/index.js'
|
|
26
|
-
|
|
27
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
|
-
const PACKAGE_ROOT = resolve(HERE, '..')
|
|
22
|
+
import { platformPackageNames, resolvePlatformBinary } from './platform-binary.js'
|
|
29
23
|
|
|
30
24
|
function defaultBinaryName() {
|
|
31
25
|
if (typeof process !== 'undefined' && process.platform === 'win32') {
|
|
@@ -36,15 +30,27 @@ function defaultBinaryName() {
|
|
|
36
30
|
|
|
37
31
|
/** SDK runtime lookup. Throws actionable error when binary cannot be located. */
|
|
38
32
|
export function resolveSdkBinary() {
|
|
33
|
+
const override = process.env?.REDDB_BIN
|
|
34
|
+
if (typeof override === 'string' && override !== '') {
|
|
35
|
+
return override
|
|
36
|
+
}
|
|
39
37
|
const legacy = process.env?.REDDB_BINARY_PATH
|
|
40
|
-
if (typeof legacy === 'string' && legacy !== ''
|
|
38
|
+
if (typeof legacy === 'string' && legacy !== '') {
|
|
41
39
|
return legacy
|
|
42
40
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
const packaged = resolvePlatformBinary()
|
|
42
|
+
if (packaged) {
|
|
43
|
+
return packaged
|
|
44
|
+
}
|
|
45
|
+
const wanted = platformPackageNames()[0]
|
|
46
|
+
throw new Error(
|
|
47
|
+
`reddb: binary "${defaultBinaryName()}" not found.\n` +
|
|
48
|
+
` expected: the ${wanted} package (an optionalDependency of @reddb-io/sdk)\n` +
|
|
49
|
+
` override: set REDDB_BIN=/path/to/${defaultBinaryName()}\n` +
|
|
50
|
+
` fix: reinstall without --no-optional / --omit=optional, and make sure\n` +
|
|
51
|
+
` ${process.platform}/${process.arch} is a supported platform;\n` +
|
|
52
|
+
` or build it: cargo build --release --bin red`,
|
|
53
|
+
)
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/** CLI runtime lookup. Allowed to fall back to PATH per ADR 0006. */
|
|
@@ -53,14 +59,5 @@ export function resolveCliBinary() {
|
|
|
53
59
|
if (typeof override === 'string' && override !== '') {
|
|
54
60
|
return override
|
|
55
61
|
}
|
|
56
|
-
|
|
57
|
-
if (existsSync(local)) {
|
|
58
|
-
return local
|
|
59
|
-
}
|
|
60
|
-
return defaultBinaryName()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Used by postinstall.js to know where to drop the downloaded binary. */
|
|
64
|
-
export function packageBinaryDir() {
|
|
65
|
-
return join(PACKAGE_ROOT, 'bin')
|
|
62
|
+
return resolvePlatformBinary() ?? defaultBinaryName()
|
|
66
63
|
}
|
package/src/cache.js
CHANGED
|
@@ -2,13 +2,9 @@
|
|
|
2
2
|
* Cache client — exposes cache.{get,put,exists,invalidate,invalidatePrefix,
|
|
3
3
|
* invalidateTags,flushNamespace} via the underlying HTTP transport.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Requires an HTTP or gRPC transport. On embedded (stdio JSON-RPC), every
|
|
10
|
-
* method throws `UNSUPPORTED_TRANSPORT` before issuing the RPC call —
|
|
11
|
-
* the engine's stdio handler doesn't implement `cache.*`.
|
|
5
|
+
* The HTTP data-plane endpoints under /cache/ns/* authorize the caller's
|
|
6
|
+
* tenant and cache namespace. Invalidation is local to the addressed server.
|
|
7
|
+
* Use the HTTP transport for remote cache operations.
|
|
12
8
|
*
|
|
13
9
|
* Values are base64-encoded in transit so binary payloads survive JSON.
|
|
14
10
|
*/
|
|
@@ -123,7 +119,7 @@ export class CacheClient {
|
|
|
123
119
|
|
|
124
120
|
/**
|
|
125
121
|
* Remove all entries in a namespace.
|
|
126
|
-
*
|
|
122
|
+
* Uses the caller-scoped POST /cache/ns/:namespace/flush endpoint.
|
|
127
123
|
* @param {string} namespace
|
|
128
124
|
* @returns {Promise<void>}
|
|
129
125
|
*/
|
package/src/http.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* cache.exists → GET /cache/ns/:ns/:key/exists
|
|
28
28
|
* cache.invalidate → DELETE /cache/ns/:ns/:key
|
|
29
29
|
* cache.invalidate_prefix → DELETE /cache/ns/:ns?prefix=...
|
|
30
|
-
* cache.invalidate_tags →
|
|
31
|
-
* cache.flush_namespace → POST /
|
|
30
|
+
* cache.invalidate_tags → POST /cache/ns/:ns/invalidate-tags (body: {tags})
|
|
31
|
+
* cache.flush_namespace → POST /cache/ns/:ns/flush
|
|
32
32
|
*
|
|
33
33
|
* Auth: every request after construction carries `Authorization:
|
|
34
34
|
* Bearer <token>` (when set). `setToken(token)` updates it in
|
|
@@ -195,11 +195,11 @@ const ROUTES = {
|
|
|
195
195
|
init: { method: 'DELETE' },
|
|
196
196
|
}),
|
|
197
197
|
'cache.invalidate_tags': (base, { namespace, tags }) => ({
|
|
198
|
-
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/tags`,
|
|
199
|
-
init: { method: '
|
|
198
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/invalidate-tags`,
|
|
199
|
+
init: { method: 'POST', body: JSON.stringify({ tags }) },
|
|
200
200
|
}),
|
|
201
201
|
'cache.flush_namespace': (base, { namespace }) => ({
|
|
202
|
-
url: `${base}/
|
|
203
|
-
init: { method: 'POST'
|
|
202
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/flush`,
|
|
203
|
+
init: { method: 'POST' },
|
|
204
204
|
}),
|
|
205
205
|
}
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { spawnRed } from './spawn.js'
|
|
22
|
+
import { NativeRpcClient, openNativeEngine, resolveNativeAddon } from './native-engine.js'
|
|
22
23
|
import { resolveSdkBinary } from './binary.js'
|
|
23
24
|
import { RpcClient, RedDBError } from './protocol.js'
|
|
24
25
|
import { parseUri } from './url.js'
|
|
@@ -83,6 +84,15 @@ export async function connect(uri, options = {}) {
|
|
|
83
84
|
'auth is only meaningful for remote connections; embedded modes inherit caller privileges.',
|
|
84
85
|
)
|
|
85
86
|
}
|
|
87
|
+
// In-process addon when one is installed for this host (and the caller
|
|
88
|
+
// did not ask for a specific binary); otherwise the `red rpc --stdio`
|
|
89
|
+
// subprocess. Both serve the same JSON-RPC protocol.
|
|
90
|
+
const addon = options.binary ? null : resolveNativeAddon()
|
|
91
|
+
if (addon) {
|
|
92
|
+
const client = new NativeRpcClient(openNativeEngine(parsed.path || undefined, addon))
|
|
93
|
+
await client.call('version', {})
|
|
94
|
+
return new RedDB(client, { transport: 'embedded' })
|
|
95
|
+
}
|
|
86
96
|
const args = embeddedArgs(parsed)
|
|
87
97
|
const binary = options.binary ?? resolveSdkBinary()
|
|
88
98
|
const child = await spawnRed(binary, args)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process embedded engine (the `reddb.node` Node addon).
|
|
3
|
+
*
|
|
4
|
+
* Embedded mode used to spawn `red rpc --stdio` and speak JSON-RPC over the
|
|
5
|
+
* child's pipes. The addon serves the same protocol — same methods, same
|
|
6
|
+
* transaction/cursor semantics — as a function call, so there is no
|
|
7
|
+
* subprocess and no separate `red` binary involved.
|
|
8
|
+
*
|
|
9
|
+
* The addon ships inside the `@reddb-io/red-<platform>` package next to
|
|
10
|
+
* `bin/red` (see `platform-binary.js` for how that package is found).
|
|
11
|
+
* `REDDB_NATIVE_ADDON=/path/to/reddb.node` overrides the lookup.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { RedDBError } from './protocol.js'
|
|
19
|
+
import { platformPackageNames } from './platform-binary.js'
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {string | null} path to `reddb.node`, or null when no addon is
|
|
25
|
+
* installed for this host.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveNativeAddon({
|
|
28
|
+
resolve = (id) => require.resolve(id),
|
|
29
|
+
env = process.env,
|
|
30
|
+
names = platformPackageNames(),
|
|
31
|
+
} = {}) {
|
|
32
|
+
const override = env.REDDB_NATIVE_ADDON
|
|
33
|
+
if (typeof override === 'string' && override !== '') return override
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
let manifest
|
|
36
|
+
try {
|
|
37
|
+
manifest = resolve(`${name}/package.json`)
|
|
38
|
+
} catch {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
const addon = join(dirname(manifest), 'reddb.node')
|
|
42
|
+
if (existsSync(addon)) return addon
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Open an embedded engine through the addon.
|
|
49
|
+
* @param {string | undefined} path database file, or undefined for in-memory
|
|
50
|
+
* @param {string} addonPath
|
|
51
|
+
*/
|
|
52
|
+
export function openNativeEngine(path, addonPath) {
|
|
53
|
+
const { Engine } = require(addonPath)
|
|
54
|
+
return Engine.open(path)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* JSON-RPC client over an in-process engine. Same surface as `RpcClient`
|
|
59
|
+
* (`call`, `close`), so `RedDB` cannot tell the transports apart.
|
|
60
|
+
*
|
|
61
|
+
* Requests are chained so they reach the engine in call order even when the
|
|
62
|
+
* caller does not await each one: the engine's thread pool would otherwise
|
|
63
|
+
* be free to reorder them, which a transaction (`tx.begin`, then writes)
|
|
64
|
+
* must never see. The stdio pipe gave this ordering for free.
|
|
65
|
+
*/
|
|
66
|
+
export class NativeRpcClient {
|
|
67
|
+
/** @param {{ call(request: string): Promise<string>, close(): void }} engine */
|
|
68
|
+
constructor(engine) {
|
|
69
|
+
this.engine = engine
|
|
70
|
+
this.nextId = 1
|
|
71
|
+
this.closed = false
|
|
72
|
+
this.closeReason = null
|
|
73
|
+
this.tail = Promise.resolve()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
call(method, params = {}) {
|
|
77
|
+
if (this.closed) {
|
|
78
|
+
return Promise.reject(
|
|
79
|
+
new RedDBError('CLIENT_CLOSED', `client is closed: ${this.closeReason ?? 'unknown'}`),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
const id = this.nextId++
|
|
83
|
+
const request = JSON.stringify({ jsonrpc: '2.0', id, method, params })
|
|
84
|
+
const run = this.tail.then(() => this.engine.call(request))
|
|
85
|
+
this.tail = run.then(
|
|
86
|
+
() => undefined,
|
|
87
|
+
() => undefined,
|
|
88
|
+
)
|
|
89
|
+
return run.then((line) => {
|
|
90
|
+
const envelope = JSON.parse(line)
|
|
91
|
+
if (method === 'close') this.#shutdown('close requested')
|
|
92
|
+
if (envelope.error) {
|
|
93
|
+
throw new RedDBError(
|
|
94
|
+
envelope.error.code ?? 'UNKNOWN',
|
|
95
|
+
envelope.error.message ?? 'unknown error',
|
|
96
|
+
envelope.error.data,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
return envelope.result
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Send `close` (discarding any open transaction), then release the file lock. */
|
|
104
|
+
async close() {
|
|
105
|
+
if (this.closed) return
|
|
106
|
+
try {
|
|
107
|
+
await this.call('close', {})
|
|
108
|
+
} catch {
|
|
109
|
+
// best effort — the engine may already be shut down
|
|
110
|
+
}
|
|
111
|
+
this.#shutdown('close requested')
|
|
112
|
+
this.engine.close()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#shutdown(reason) {
|
|
116
|
+
if (this.closed) return
|
|
117
|
+
this.closed = true
|
|
118
|
+
this.closeReason = reason
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate the `red` binary shipped as a per-platform npm package.
|
|
3
|
+
*
|
|
4
|
+
* `@reddb-io/cli` and `@reddb-io/sdk` declare every `@reddb-io/red-<key>`
|
|
5
|
+
* package as an `optionalDependency`; the package manager installs only the
|
|
6
|
+
* one matching the host's `os` / `cpu` / `libc`, so there is no install
|
|
7
|
+
* script and no download. `<key>` is `<process.platform>-<process.arch>`,
|
|
8
|
+
* plus `-musl` for the static Linux builds. `scripts/npm-platform-packages.mjs`
|
|
9
|
+
* owns the publish side; a contract test keeps the two key tables identical.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
|
|
18
|
+
/** `glibc` | `musl` on Linux, `null` elsewhere. */
|
|
19
|
+
export function detectLibc(platform = process.platform) {
|
|
20
|
+
if (platform !== 'linux') return null
|
|
21
|
+
const header = process.report?.getReport?.()?.header
|
|
22
|
+
return header?.glibcVersionRuntime ? 'glibc' : 'musl'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Package names to try, best match first. On Linux the other libc variant is
|
|
27
|
+
* a fallback: package managers that ignore `libc` install both, and the
|
|
28
|
+
* static (musl) binary runs on glibc hosts too.
|
|
29
|
+
*/
|
|
30
|
+
export function platformPackageNames({
|
|
31
|
+
platform = process.platform,
|
|
32
|
+
arch = process.arch,
|
|
33
|
+
libc = detectLibc(platform),
|
|
34
|
+
} = {}) {
|
|
35
|
+
const base = `@reddb-io/red-${platform}-${arch}`
|
|
36
|
+
if (platform !== 'linux') return [base]
|
|
37
|
+
return libc === 'musl' ? [`${base}-musl`, base] : [base, `${base}-musl`]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {string | null} absolute path to the binary, or null when no
|
|
42
|
+
* platform package for this host is installed.
|
|
43
|
+
*/
|
|
44
|
+
export function resolvePlatformBinary({
|
|
45
|
+
platform = process.platform,
|
|
46
|
+
arch = process.arch,
|
|
47
|
+
libc = detectLibc(platform),
|
|
48
|
+
resolve = (id) => require.resolve(id),
|
|
49
|
+
} = {}) {
|
|
50
|
+
const binName = platform === 'win32' ? 'red.exe' : 'red'
|
|
51
|
+
for (const name of platformPackageNames({ platform, arch, libc })) {
|
|
52
|
+
let manifest
|
|
53
|
+
try {
|
|
54
|
+
manifest = resolve(`${name}/package.json`)
|
|
55
|
+
} catch {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const binary = join(dirname(manifest), 'bin', binName)
|
|
59
|
+
if (existsSync(binary)) return binary
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
package/src/vault.js
CHANGED
|
@@ -23,9 +23,13 @@ export class VaultClient {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
unseal(key, options = {}) {
|
|
26
|
+
return this.reveal(key, options)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
reveal(key, options = {}) {
|
|
26
30
|
const collection = options.collection ?? this.collection
|
|
27
31
|
return this.client.call('query', {
|
|
28
|
-
sql: `
|
|
32
|
+
sql: `VAULT REVEAL ${keyedIdentifier(collection)}.${keyedIdentifier(key)}`,
|
|
29
33
|
})
|
|
30
34
|
}
|
|
31
35
|
}
|
package/postinstall.js
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* postinstall.js — download the matching `red` binary from GitHub Releases.
|
|
3
|
-
*
|
|
4
|
-
* Behavior:
|
|
5
|
-
* - Resolves `red-<platform>-<arch>` from `process.platform` + `process.arch`
|
|
6
|
-
* via the vendored asset fetcher.
|
|
7
|
-
* - Targets the GitHub release matching this package's version.
|
|
8
|
-
* - Drops the binary at `<package>/bin/red[.exe]` and chmods +x on Unix.
|
|
9
|
-
* - Intentional skip paths (workspace checkout, REDDB_SKIP_POSTINSTALL=1)
|
|
10
|
-
* exit 0 quietly. Any unintentional failure (no network, 404, asset
|
|
11
|
-
* fetcher error, unsupported platform) prints an actionable multi-line
|
|
12
|
-
* message to stderr and exits 1 so `npm install` fails loud — never
|
|
13
|
-
* ships a package with an empty bin/ that explodes later at connect().
|
|
14
|
-
*
|
|
15
|
-
* Override hooks (env vars):
|
|
16
|
-
* REDDB_SKIP_POSTINSTALL=1 do nothing, exit 0 (caller will provide
|
|
17
|
-
* the binary via REDDB_BIN or a vendored
|
|
18
|
-
* copy at bin/red[.exe])
|
|
19
|
-
* REDDB_POSTINSTALL_VERSION=… pull a different release tag
|
|
20
|
-
* REDDB_POSTINSTALL_REPO=… pull from a fork (default: reddb-io/reddb)
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { createRequire } from 'node:module'
|
|
24
|
-
import { fileURLToPath } from 'node:url'
|
|
25
|
-
import { dirname, join, sep } from 'node:path'
|
|
26
|
-
import { existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
|
|
27
|
-
|
|
28
|
-
import { fetchReleaseAsset } from './src/internal/asset-fetcher/index.js'
|
|
29
|
-
|
|
30
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
31
|
-
const require = createRequire(import.meta.url)
|
|
32
|
-
const pkg = require('./package.json')
|
|
33
|
-
|
|
34
|
-
const DEFAULT_REPO = 'reddb-io/reddb'
|
|
35
|
-
|
|
36
|
-
if (process.env.REDDB_SKIP_POSTINSTALL === '1') {
|
|
37
|
-
process.stdout.write('reddb: postinstall skipped (REDDB_SKIP_POSTINSTALL=1)\n')
|
|
38
|
-
process.exit(0)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Workspace-local install detection. When this file's resolved path is not
|
|
42
|
-
// inside a `node_modules` tree we're running from a fresh checkout of the
|
|
43
|
-
// reddb monorepo (or a fork), where `package.json` may already carry a
|
|
44
|
-
// version whose GitHub Release has not been built yet — and where the
|
|
45
|
-
// developer typically builds `red` with `cargo build` anyway. Skip the
|
|
46
|
-
// download with a clear pointer; release flows still work because the npm
|
|
47
|
-
// tarball, when installed, lives under `node_modules/`.
|
|
48
|
-
if (!HERE.includes(`${sep}node_modules${sep}`)) {
|
|
49
|
-
process.stdout.write(
|
|
50
|
-
'reddb: skipping postinstall — running from a workspace checkout (no node_modules/).\n' +
|
|
51
|
-
' If you need the binary, either:\n' +
|
|
52
|
-
' - cargo build --release --bin red && export REDDB_BIN="$PWD/target/release/red"\n' +
|
|
53
|
-
' - or: curl -fsSL https://raw.githubusercontent.com/reddb-io/reddb/main/install.sh | bash\n' +
|
|
54
|
-
' Set REDDB_SKIP_POSTINSTALL=1 to silence this message.\n',
|
|
55
|
-
)
|
|
56
|
-
process.exit(0)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
main().catch((err) => {
|
|
60
|
-
process.stderr.write(formatFailure(err))
|
|
61
|
-
process.exit(1)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
function formatFailure(err) {
|
|
65
|
-
const repo = process.env.REDDB_POSTINSTALL_REPO || DEFAULT_REPO
|
|
66
|
-
const escapeHatches =
|
|
67
|
-
` Escape hatches (any one of these will unblock the install):\n` +
|
|
68
|
-
` - Skip the download and provide the binary yourself:\n` +
|
|
69
|
-
` REDDB_SKIP_POSTINSTALL=1 npm install\n` +
|
|
70
|
-
` then at runtime: export REDDB_BIN=/path/to/red\n` +
|
|
71
|
-
` - Or install red via the official installer and point at it:\n` +
|
|
72
|
-
` curl -fsSL https://raw.githubusercontent.com/${repo}/main/install.sh | bash\n` +
|
|
73
|
-
` export REDDB_BIN="$(command -v red)"\n` +
|
|
74
|
-
` - Or build from a workspace checkout of ${repo}:\n` +
|
|
75
|
-
` cargo build --release --bin red\n` +
|
|
76
|
-
` export REDDB_BIN="$PWD/target/release/red"\n` +
|
|
77
|
-
` - Or download the binary manually from\n` +
|
|
78
|
-
` https://github.com/${repo}/releases\n` +
|
|
79
|
-
` and place it at <package>/bin/red[.exe]\n`
|
|
80
|
-
|
|
81
|
-
if (err && err.code === 'UNSUPPORTED_PLATFORM') {
|
|
82
|
-
return (
|
|
83
|
-
`reddb: no prebuilt red binary for ${process.platform}/${process.arch}.\n` +
|
|
84
|
-
` Building from source is required for this platform:\n` +
|
|
85
|
-
` https://github.com/${repo}#build-from-source\n` +
|
|
86
|
-
escapeHatches
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
|
-
if (err && err.code === 'ASSET_NOT_FOUND') {
|
|
90
|
-
return (
|
|
91
|
-
`reddb: release asset not found at ${err.url}\n` +
|
|
92
|
-
` Common cause: the GitHub Release for this SDK version has not\n` +
|
|
93
|
-
` been published yet (or your platform's binary was not produced\n` +
|
|
94
|
-
` for that release). You can also pull a specific tag explicitly:\n` +
|
|
95
|
-
` REDDB_POSTINSTALL_VERSION=v1.0.5 npm rebuild @reddb-io/sdk\n` +
|
|
96
|
-
escapeHatches
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
return (
|
|
100
|
-
`reddb: postinstall could not download the red binary (${(err && err.message) || err}).\n` +
|
|
101
|
-
` Install failed — the SDK will not work without a binary at\n` +
|
|
102
|
-
` <package>/bin/red[.exe] or pointed at by REDDB_BIN.\n` +
|
|
103
|
-
escapeHatches
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function main() {
|
|
108
|
-
const repo = process.env.REDDB_POSTINSTALL_REPO || DEFAULT_REPO
|
|
109
|
-
const tag = process.env.REDDB_POSTINSTALL_VERSION
|
|
110
|
-
? normalizeTag(process.env.REDDB_POSTINSTALL_VERSION)
|
|
111
|
-
: `v${pkg.version}`
|
|
112
|
-
|
|
113
|
-
const binDir = join(HERE, 'bin')
|
|
114
|
-
const binaryPath = join(binDir, defaultBinaryName())
|
|
115
|
-
|
|
116
|
-
if (existsSync(binaryPath)) {
|
|
117
|
-
process.stdout.write(`reddb: binary already present at ${binaryPath}\n`)
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
process.stdout.write(
|
|
122
|
-
`reddb: downloading red ${tag} for ${process.platform}/${process.arch} from ${repo}\n`,
|
|
123
|
-
)
|
|
124
|
-
const body = await fetchReleaseAsset({
|
|
125
|
-
repo,
|
|
126
|
-
tag,
|
|
127
|
-
platform: process.platform,
|
|
128
|
-
arch: process.arch,
|
|
129
|
-
binName: 'red',
|
|
130
|
-
})
|
|
131
|
-
mkdirSync(binDir, { recursive: true })
|
|
132
|
-
writeFileSync(binaryPath, body)
|
|
133
|
-
if (process.platform !== 'win32') {
|
|
134
|
-
chmodSync(binaryPath, 0o755)
|
|
135
|
-
}
|
|
136
|
-
process.stdout.write(`reddb: installed binary at ${binaryPath}\n`)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function defaultBinaryName() {
|
|
140
|
-
return process.platform === 'win32' ? 'red.exe' : 'red'
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function normalizeTag(value) {
|
|
144
|
-
const v = String(value).trim()
|
|
145
|
-
return v.startsWith('v') ? v : `v${v}`
|
|
146
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve the GitHub release asset filename for a given Node
|
|
3
|
-
* `process.platform` / `process.arch` pair and binary base name.
|
|
4
|
-
*
|
|
5
|
-
* Mapping is the existing scheme from `drivers/js/postinstall.js`:
|
|
6
|
-
* linux + x64 → <bin>-linux-x86_64
|
|
7
|
-
* linux + arm64 → <bin>-linux-aarch64
|
|
8
|
-
* linux + arm/armv7l→ <bin>-linux-armv7
|
|
9
|
-
* darwin + x64 → <bin>-macos-x86_64
|
|
10
|
-
* darwin + arm64 → <bin>-macos-aarch64
|
|
11
|
-
* win32 + x64 → <bin>-windows-x86_64.exe
|
|
12
|
-
*
|
|
13
|
-
* Throws `UnsupportedPlatformError` for any other combination.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export class UnsupportedPlatformError extends Error {
|
|
17
|
-
constructor(platform, arch) {
|
|
18
|
-
super(`unsupported platform/arch combination: ${platform}/${arch}`)
|
|
19
|
-
this.name = 'UnsupportedPlatformError'
|
|
20
|
-
this.code = 'UNSUPPORTED_PLATFORM'
|
|
21
|
-
this.platform = platform
|
|
22
|
-
this.arch = arch
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function composeAssetName({ platform, arch, binName }) {
|
|
27
|
-
if (typeof binName !== 'string' || binName === '') {
|
|
28
|
-
throw new TypeError('composeAssetName: `binName` must be a non-empty string')
|
|
29
|
-
}
|
|
30
|
-
if (platform === 'linux' && arch === 'x64') return `${binName}-linux-x86_64`
|
|
31
|
-
if (platform === 'linux' && arch === 'arm64') return `${binName}-linux-aarch64`
|
|
32
|
-
if (platform === 'linux' && (arch === 'arm' || arch === 'armv7l')) return `${binName}-linux-armv7`
|
|
33
|
-
if (platform === 'darwin' && arch === 'x64') return `${binName}-macos-x86_64`
|
|
34
|
-
if (platform === 'darwin' && arch === 'arm64') return `${binName}-macos-aarch64`
|
|
35
|
-
if (platform === 'win32' && arch === 'x64') return `${binName}-windows-x86_64.exe`
|
|
36
|
-
throw new UnsupportedPlatformError(platform, arch)
|
|
37
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
export class ChecksumMismatchError extends Error {
|
|
4
|
-
constructor(expected, actual) {
|
|
5
|
-
super(`checksum mismatch: expected sha256=${expected}, got sha256=${actual}`)
|
|
6
|
-
this.name = 'ChecksumMismatchError'
|
|
7
|
-
this.code = 'CHECKSUM_MISMATCH'
|
|
8
|
-
this.expected = expected
|
|
9
|
-
this.actual = actual
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function sha256Hex(buf) {
|
|
14
|
-
return createHash('sha256').update(buf).digest('hex')
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function verifySha256(buf, expected) {
|
|
18
|
-
const expectedNorm = String(expected).trim().toLowerCase()
|
|
19
|
-
const actual = sha256Hex(buf)
|
|
20
|
-
if (actual !== expectedNorm) {
|
|
21
|
-
throw new ChecksumMismatchError(expectedNorm, actual)
|
|
22
|
-
}
|
|
23
|
-
}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { request as httpsRequest } from 'node:https'
|
|
2
|
-
import { request as httpRequest } from 'node:http'
|
|
3
|
-
|
|
4
|
-
const MAX_REDIRECTS = 5
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Hosts a release download may be served from. The postinstall script writes
|
|
8
|
-
* the response to disk and then executes it, so an unrestricted redirect
|
|
9
|
-
* chain (or a plain-http hop) turns any network position into code
|
|
10
|
-
* execution on the installing machine. GitHub serves release assets from
|
|
11
|
-
* `github.com` and redirects to its object storage.
|
|
12
|
-
*/
|
|
13
|
-
const ALLOWED_HOSTS = new Set([
|
|
14
|
-
'github.com',
|
|
15
|
-
'objects.githubusercontent.com',
|
|
16
|
-
'release-assets.githubusercontent.com',
|
|
17
|
-
'raw.githubusercontent.com',
|
|
18
|
-
])
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Ceiling on a downloaded asset. The `red` binary is tens of megabytes; a
|
|
22
|
-
* response without this cap is buffered in full whatever its size.
|
|
23
|
-
*/
|
|
24
|
-
const MAX_BODY_BYTES = 512 * 1024 * 1024
|
|
25
|
-
|
|
26
|
-
export class DisallowedHostError extends Error {
|
|
27
|
-
constructor(url) {
|
|
28
|
-
super(`refusing to download from a non-allowlisted host: ${url}`)
|
|
29
|
-
this.name = 'DisallowedHostError'
|
|
30
|
-
this.code = 'DISALLOWED_HOST'
|
|
31
|
-
this.url = url
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class ResponseTooLargeError extends Error {
|
|
36
|
-
constructor(url, limit) {
|
|
37
|
-
super(`response from ${url} exceeds the ${limit}-byte limit`)
|
|
38
|
-
this.name = 'ResponseTooLargeError'
|
|
39
|
-
this.code = 'RESPONSE_TOO_LARGE'
|
|
40
|
-
this.url = url
|
|
41
|
-
this.limit = limit
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Reject anything that is not https on an allowlisted host. Applied to the
|
|
47
|
-
* initial URL and to every redirect target, since a redirect is exactly how
|
|
48
|
-
* an attacker would leave the allowlist.
|
|
49
|
-
*
|
|
50
|
-
* `allowedHosts` exists so the test suite can point the transport at a local
|
|
51
|
-
* server; production callers never pass it and get {@link ALLOWED_HOSTS}
|
|
52
|
-
* plus the https requirement.
|
|
53
|
-
*/
|
|
54
|
-
export function assertDownloadableUrl(url, allowedHosts = ALLOWED_HOSTS) {
|
|
55
|
-
let parsed
|
|
56
|
-
try {
|
|
57
|
-
parsed = new URL(url)
|
|
58
|
-
} catch {
|
|
59
|
-
throw new DisallowedHostError(url)
|
|
60
|
-
}
|
|
61
|
-
const isDefaultPolicy = allowedHosts === ALLOWED_HOSTS
|
|
62
|
-
if (isDefaultPolicy && parsed.protocol !== 'https:') {
|
|
63
|
-
throw new DisallowedHostError(url)
|
|
64
|
-
}
|
|
65
|
-
if (!allowedHosts.has(parsed.hostname)) {
|
|
66
|
-
throw new DisallowedHostError(url)
|
|
67
|
-
}
|
|
68
|
-
return parsed
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export class AssetNotFoundError extends Error {
|
|
72
|
-
constructor(url) {
|
|
73
|
-
super(`asset not found (HTTP 404) at ${url}`)
|
|
74
|
-
this.name = 'AssetNotFoundError'
|
|
75
|
-
this.code = 'ASSET_NOT_FOUND'
|
|
76
|
-
this.url = url
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export class HttpError extends Error {
|
|
81
|
-
constructor(status, url) {
|
|
82
|
-
super(`HTTP ${status} fetching ${url}`)
|
|
83
|
-
this.name = 'HttpError'
|
|
84
|
-
this.code = 'HTTP_ERROR'
|
|
85
|
-
this.status = status
|
|
86
|
-
this.url = url
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export class TooManyRedirectsError extends Error {
|
|
91
|
-
constructor(url) {
|
|
92
|
-
super(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`)
|
|
93
|
-
this.name = 'TooManyRedirectsError'
|
|
94
|
-
this.code = 'TOO_MANY_REDIRECTS'
|
|
95
|
-
this.url = url
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function resolveLocation(currentUrl, location) {
|
|
100
|
-
if (/^https?:\/\//i.test(location)) return location
|
|
101
|
-
return new URL(location, currentUrl).toString()
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function downloadFollowingRedirects(
|
|
105
|
-
url,
|
|
106
|
-
{ userAgent, originalUrl, allowedHosts } = {},
|
|
107
|
-
depth = 0,
|
|
108
|
-
) {
|
|
109
|
-
const startUrl = originalUrl || url
|
|
110
|
-
if (depth > MAX_REDIRECTS) {
|
|
111
|
-
return Promise.reject(new TooManyRedirectsError(startUrl))
|
|
112
|
-
}
|
|
113
|
-
let parsed
|
|
114
|
-
try {
|
|
115
|
-
parsed = assertDownloadableUrl(url, allowedHosts)
|
|
116
|
-
} catch (err) {
|
|
117
|
-
return Promise.reject(err)
|
|
118
|
-
}
|
|
119
|
-
const request = parsed.protocol === 'http:' ? httpRequest : httpsRequest
|
|
120
|
-
return new Promise((resolve, reject) => {
|
|
121
|
-
const req = request(
|
|
122
|
-
url,
|
|
123
|
-
{
|
|
124
|
-
method: 'GET',
|
|
125
|
-
headers: {
|
|
126
|
-
'User-Agent': userAgent || 'reddb-internal-asset-fetcher',
|
|
127
|
-
Accept: 'application/octet-stream',
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
(res) => {
|
|
131
|
-
const status = res.statusCode || 0
|
|
132
|
-
if (status >= 300 && status < 400 && res.headers.location) {
|
|
133
|
-
res.resume()
|
|
134
|
-
const next = resolveLocation(url, res.headers.location)
|
|
135
|
-
downloadFollowingRedirects(
|
|
136
|
-
next,
|
|
137
|
-
{ userAgent, originalUrl: startUrl, allowedHosts },
|
|
138
|
-
depth + 1,
|
|
139
|
-
).then(resolve, reject)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
if (status === 404) {
|
|
143
|
-
res.resume()
|
|
144
|
-
reject(new AssetNotFoundError(startUrl))
|
|
145
|
-
return
|
|
146
|
-
}
|
|
147
|
-
if (status < 200 || status >= 300) {
|
|
148
|
-
res.resume()
|
|
149
|
-
reject(new HttpError(status, url))
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
const chunks = []
|
|
153
|
-
let received = 0
|
|
154
|
-
res.on('data', (chunk) => {
|
|
155
|
-
received += chunk.length
|
|
156
|
-
if (received > MAX_BODY_BYTES) {
|
|
157
|
-
res.destroy()
|
|
158
|
-
reject(new ResponseTooLargeError(startUrl, MAX_BODY_BYTES))
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
chunks.push(chunk)
|
|
162
|
-
})
|
|
163
|
-
res.on('end', () => resolve(Buffer.concat(chunks)))
|
|
164
|
-
res.on('error', reject)
|
|
165
|
-
},
|
|
166
|
-
)
|
|
167
|
-
req.on('error', reject)
|
|
168
|
-
req.end()
|
|
169
|
-
})
|
|
170
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @reddb-io/internal-asset-fetcher — fetch a `red`/`red_client` binary
|
|
3
|
-
* from a GitHub release.
|
|
4
|
-
*
|
|
5
|
-
* Public surface: one function.
|
|
6
|
-
*
|
|
7
|
-
* fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256? }) → Buffer
|
|
8
|
-
*
|
|
9
|
-
* Steps:
|
|
10
|
-
* 1. Map (platform, arch, binName) → asset filename.
|
|
11
|
-
* 2. Compose the GitHub download URL: `https://github.com/<repo>/releases/download/<tag>/<asset>`.
|
|
12
|
-
* 3. Follow up to 5 redirects, returning the final body as a Buffer.
|
|
13
|
-
* 4. If `sha256` was supplied, verify before returning.
|
|
14
|
-
*
|
|
15
|
-
* Errors carry distinct `.code` values so callers can differentiate:
|
|
16
|
-
* - UNSUPPORTED_PLATFORM — no asset for this platform/arch
|
|
17
|
-
* - ASSET_NOT_FOUND — HTTP 404 (release/tag/asset name wrong)
|
|
18
|
-
* - CHECKSUM_MISMATCH — body downloaded but sha256 mismatched
|
|
19
|
-
* - HTTP_ERROR — any other non-2xx status
|
|
20
|
-
* - TOO_MANY_REDIRECTS — redirect chain longer than 5 hops
|
|
21
|
-
*
|
|
22
|
-
* Internal modules (`./asset-name.js`, `./download.js`, `./checksum.js`)
|
|
23
|
-
* are not part of the public contract — only `fetchReleaseAsset` is.
|
|
24
|
-
* They are imported directly in tests for focused coverage.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { composeAssetName } from './asset-name.js'
|
|
28
|
-
import { downloadFollowingRedirects } from './download.js'
|
|
29
|
-
import { sha256Hex, verifySha256, ChecksumMismatchError } from './checksum.js'
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* A GitHub `owner/name` pair. `repo` reaches this function from
|
|
33
|
-
* `REDDB_POSTINSTALL_REPO` / `REDDB_MCP_REPO`, and is interpolated into the
|
|
34
|
-
* download URL, so a value containing a path or authority would point the
|
|
35
|
-
* download somewhere else entirely.
|
|
36
|
-
*/
|
|
37
|
-
const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
|
|
38
|
-
|
|
39
|
-
/** A release tag, as it appears in the download URL path. */
|
|
40
|
-
const TAG_PATTERN = /^[A-Za-z0-9._+-]+$/
|
|
41
|
-
|
|
42
|
-
export class ChecksumUnavailableError extends Error {
|
|
43
|
-
constructor(assetName) {
|
|
44
|
-
super(
|
|
45
|
-
`no SHA256SUMS entry for ${assetName}; refusing to install an unverified binary ` +
|
|
46
|
-
`(set REDDB_POSTINSTALL_ALLOW_UNVERIFIED=1 to override)`,
|
|
47
|
-
)
|
|
48
|
-
this.name = 'ChecksumUnavailableError'
|
|
49
|
-
this.code = 'CHECKSUM_UNAVAILABLE'
|
|
50
|
-
this.assetName = assetName
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Parse a `SHA256SUMS` file (`<hex> <filename>` per line) and return the
|
|
56
|
-
* digest recorded for `assetName`, or `null` when it is not listed.
|
|
57
|
-
*/
|
|
58
|
-
export function sha256FromSumsFile(text, assetName) {
|
|
59
|
-
for (const line of String(text).split('\n')) {
|
|
60
|
-
const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/)
|
|
61
|
-
if (match && match[2].trim() === assetName) {
|
|
62
|
-
return match[1].toLowerCase()
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
|
|
69
|
-
if (typeof repo !== 'string' || repo === '') {
|
|
70
|
-
throw new TypeError('fetchReleaseAsset: `repo` must be a non-empty string (e.g. "reddb-io/reddb")')
|
|
71
|
-
}
|
|
72
|
-
if (typeof tag !== 'string' || tag === '') {
|
|
73
|
-
throw new TypeError('fetchReleaseAsset: `tag` must be a non-empty string (e.g. "v0.2.9")')
|
|
74
|
-
}
|
|
75
|
-
if (typeof platform !== 'string' || platform === '') {
|
|
76
|
-
throw new TypeError('fetchReleaseAsset: `platform` must be a non-empty string')
|
|
77
|
-
}
|
|
78
|
-
if (typeof arch !== 'string' || arch === '') {
|
|
79
|
-
throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!REPO_PATTERN.test(repo)) {
|
|
83
|
-
throw new TypeError(`fetchReleaseAsset: \`repo\` must be "owner/name", got ${JSON.stringify(repo)}`)
|
|
84
|
-
}
|
|
85
|
-
if (!TAG_PATTERN.test(tag)) {
|
|
86
|
-
throw new TypeError(`fetchReleaseAsset: \`tag\` contains unsupported characters: ${JSON.stringify(tag)}`)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const assetName = composeAssetName({ platform, arch, binName })
|
|
90
|
-
const base = `https://github.com/${repo}/releases/download/${tag}`
|
|
91
|
-
const body = await downloadFollowingRedirects(`${base}/${assetName}`)
|
|
92
|
-
|
|
93
|
-
// The caller may pin a digest; otherwise fall back to the `SHA256SUMS`
|
|
94
|
-
// file the release publishes beside the assets. The downloaded bytes are
|
|
95
|
-
// written to disk and executed, so installing them unverified is the one
|
|
96
|
-
// outcome worth failing the install over — `install.sh` has verified since
|
|
97
|
-
// it shipped, and this path had not.
|
|
98
|
-
if (sha256) {
|
|
99
|
-
verifySha256(body, sha256)
|
|
100
|
-
return body
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let expected = null
|
|
104
|
-
try {
|
|
105
|
-
const sums = await downloadFollowingRedirects(`${base}/SHA256SUMS`)
|
|
106
|
-
expected = sha256FromSumsFile(sums.toString('utf8'), assetName)
|
|
107
|
-
} catch {
|
|
108
|
-
expected = null
|
|
109
|
-
}
|
|
110
|
-
if (expected) {
|
|
111
|
-
if (sha256Hex(body) !== expected) {
|
|
112
|
-
throw new ChecksumMismatchError(expected, sha256Hex(body))
|
|
113
|
-
}
|
|
114
|
-
return body
|
|
115
|
-
}
|
|
116
|
-
if (allowUnverified()) {
|
|
117
|
-
return body
|
|
118
|
-
}
|
|
119
|
-
throw new ChecksumUnavailableError(assetName)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Escape hatch for installing from a release that predates `SHA256SUMS`, or
|
|
124
|
-
* from a fork that does not publish one. Opt-in and loud rather than the
|
|
125
|
-
* silent default it used to be.
|
|
126
|
-
*/
|
|
127
|
-
function allowUnverified() {
|
|
128
|
-
const raw = process.env.REDDB_POSTINSTALL_ALLOW_UNVERIFIED
|
|
129
|
-
return raw === '1' || raw === 'true' || raw === 'yes'
|
|
130
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @reddb-io/internal-bin-resolver — runtime lookup for a pinned binary.
|
|
3
|
-
*
|
|
4
|
-
* Precedence:
|
|
5
|
-
* 1. `process.env[envVar]` if set and non-empty → returned verbatim.
|
|
6
|
-
* No existence probe: the env var is the user's "I know what I'm
|
|
7
|
-
* doing" override.
|
|
8
|
-
* 2. `<packageRoot>/bin/<name>` if it exists.
|
|
9
|
-
* 3. Otherwise throw an actionable error naming the env var, the
|
|
10
|
-
* expected local path, and a one-line `pnpm install` hint.
|
|
11
|
-
*
|
|
12
|
-
* `PATH` is deliberately never consulted. SDK/client wire formats are
|
|
13
|
-
* version-coupled to the binary; silent fallback to a stale `PATH`
|
|
14
|
-
* binary fails as misframed RPC, not "command not found". See ADR 0006.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { existsSync } from 'node:fs'
|
|
18
|
-
import { join } from 'node:path'
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @param {{ name: string, packageRoot: string, envVar: string }} opts
|
|
22
|
-
* @returns {string} absolute path to the binary
|
|
23
|
-
* @throws {Error} when neither env override nor local binary is usable
|
|
24
|
-
*/
|
|
25
|
-
export function resolveBin(opts) {
|
|
26
|
-
if (!opts || typeof opts !== 'object') {
|
|
27
|
-
throw new TypeError('resolveBin: options object required')
|
|
28
|
-
}
|
|
29
|
-
const { name, packageRoot, envVar } = opts
|
|
30
|
-
if (typeof name !== 'string' || name === '') {
|
|
31
|
-
throw new TypeError('resolveBin: `name` must be a non-empty string')
|
|
32
|
-
}
|
|
33
|
-
if (typeof packageRoot !== 'string' || packageRoot === '') {
|
|
34
|
-
throw new TypeError('resolveBin: `packageRoot` must be a non-empty string')
|
|
35
|
-
}
|
|
36
|
-
if (typeof envVar !== 'string' || envVar === '') {
|
|
37
|
-
throw new TypeError('resolveBin: `envVar` must be a non-empty string')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const override = process.env?.[envVar]
|
|
41
|
-
if (typeof override === 'string' && override !== '') {
|
|
42
|
-
return override
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const local = join(packageRoot, 'bin', name)
|
|
46
|
-
if (existsSync(local)) {
|
|
47
|
-
return local
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
throw new Error(
|
|
51
|
-
`reddb: binary "${name}" not found.\n` +
|
|
52
|
-
` expected at: ${local}\n` +
|
|
53
|
-
` override: set ${envVar}=/path/to/${name}\n` +
|
|
54
|
-
` fix: re-run \`pnpm install\` (the postinstall script downloads it),\n` +
|
|
55
|
-
` or check the postinstall log for a download error.`,
|
|
56
|
-
)
|
|
57
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @reddb-io/internal-version-compare — install/upgrade/skip verdict for
|
|
3
|
-
* the CLI postinstall.
|
|
4
|
-
*
|
|
5
|
-
* Asymmetry vs SDK/client (per ADR 0006):
|
|
6
|
-
* - SDK/client postinstalls *always* download a pinned binary because
|
|
7
|
-
* the wire format is version-coupled to the driver.
|
|
8
|
-
* - The CLI is a launcher. Users typically already have `red` on PATH
|
|
9
|
-
* and would resent each `npm i -g @reddb-io/cli` re-downloading.
|
|
10
|
-
* We compare versions and only fetch when strictly newer.
|
|
11
|
-
*
|
|
12
|
-
* Verdict table:
|
|
13
|
-
* exec throws → action='install' (no binary detected)
|
|
14
|
-
* exec returns unparseable output → action='install' (warn, fetch fresh)
|
|
15
|
-
* PATH version < packageVersion → action='upgrade'
|
|
16
|
-
* PATH version >= packageVersion → action='skip'
|
|
17
|
-
*
|
|
18
|
-
* Prerelease ordering follows semver 2.0.0 §11: a normal release ranks
|
|
19
|
-
* above any prerelease of the same MAJOR.MINOR.PATCH.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
const SEMVER_RE = /\b(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?\b/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Extract a semver-shaped version string from arbitrary `red --version`
|
|
26
|
-
* output. Accepts `reddb 0.2.9`, bare `0.2.9`, or `red 1.0.0-rc.1`.
|
|
27
|
-
*
|
|
28
|
-
* @param {string} text
|
|
29
|
-
* @returns {string|null} normalised `MAJOR.MINOR.PATCH[-prerelease]` or null
|
|
30
|
-
*/
|
|
31
|
-
export function parseVersion(text) {
|
|
32
|
-
if (typeof text !== 'string') return null
|
|
33
|
-
const m = text.match(SEMVER_RE)
|
|
34
|
-
if (!m) return null
|
|
35
|
-
const [, maj, min, pat, pre] = m
|
|
36
|
-
return pre ? `${maj}.${min}.${pat}-${pre}` : `${maj}.${min}.${pat}`
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* semver 2.0.0 ordering. Returns -1 / 0 / 1.
|
|
41
|
-
*
|
|
42
|
-
* Throws TypeError if either side is not parseable — this is a
|
|
43
|
-
* programmer error, not a runtime fallback path.
|
|
44
|
-
*/
|
|
45
|
-
export function compareSemver(a, b) {
|
|
46
|
-
const pa = parseVersion(a)
|
|
47
|
-
const pb = parseVersion(b)
|
|
48
|
-
if (pa === null) throw new TypeError(`compareSemver: invalid version "${a}"`)
|
|
49
|
-
if (pb === null) throw new TypeError(`compareSemver: invalid version "${b}"`)
|
|
50
|
-
|
|
51
|
-
const [coreA, preA] = splitCore(pa)
|
|
52
|
-
const [coreB, preB] = splitCore(pb)
|
|
53
|
-
|
|
54
|
-
for (let i = 0; i < 3; i++) {
|
|
55
|
-
if (coreA[i] !== coreB[i]) return coreA[i] < coreB[i] ? -1 : 1
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Cores equal — apply §11: no-prerelease ranks above any prerelease.
|
|
59
|
-
if (preA === null && preB === null) return 0
|
|
60
|
-
if (preA === null) return 1
|
|
61
|
-
if (preB === null) return -1
|
|
62
|
-
|
|
63
|
-
return comparePrerelease(preA, preB)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function splitCore(v) {
|
|
67
|
-
const dash = v.indexOf('-')
|
|
68
|
-
const core = dash === -1 ? v : v.slice(0, dash)
|
|
69
|
-
const pre = dash === -1 ? null : v.slice(dash + 1)
|
|
70
|
-
const parts = core.split('.').map((n) => Number(n))
|
|
71
|
-
return [parts, pre]
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function comparePrerelease(a, b) {
|
|
75
|
-
const sa = a.split('.')
|
|
76
|
-
const sb = b.split('.')
|
|
77
|
-
const len = Math.min(sa.length, sb.length)
|
|
78
|
-
for (let i = 0; i < len; i++) {
|
|
79
|
-
const c = compareIdentifier(sa[i], sb[i])
|
|
80
|
-
if (c !== 0) return c
|
|
81
|
-
}
|
|
82
|
-
if (sa.length === sb.length) return 0
|
|
83
|
-
return sa.length < sb.length ? -1 : 1
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function compareIdentifier(a, b) {
|
|
87
|
-
const aNum = /^\d+$/.test(a)
|
|
88
|
-
const bNum = /^\d+$/.test(b)
|
|
89
|
-
if (aNum && bNum) {
|
|
90
|
-
const na = Number(a)
|
|
91
|
-
const nb = Number(b)
|
|
92
|
-
return na === nb ? 0 : na < nb ? -1 : 1
|
|
93
|
-
}
|
|
94
|
-
if (aNum) return -1 // numeric < alphanumeric per §11
|
|
95
|
-
if (bNum) return 1
|
|
96
|
-
return a === b ? 0 : a < b ? -1 : 1
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Decide what the CLI postinstall should do.
|
|
101
|
-
*
|
|
102
|
-
* @param {{ packageVersion: string, exec: () => string }} opts
|
|
103
|
-
* `exec` is called with no arguments and is expected to return the
|
|
104
|
-
* stdout of `red --version` (sync). It may throw — that signals "no
|
|
105
|
-
* PATH binary detected" and routes to action='install'.
|
|
106
|
-
* @returns {{ action: 'install'|'upgrade'|'skip', reason: string }}
|
|
107
|
-
*/
|
|
108
|
-
export function compareInstalled(opts) {
|
|
109
|
-
if (!opts || typeof opts !== 'object') {
|
|
110
|
-
throw new TypeError('compareInstalled: options object required')
|
|
111
|
-
}
|
|
112
|
-
const { packageVersion, exec } = opts
|
|
113
|
-
if (typeof packageVersion !== 'string' || packageVersion === '') {
|
|
114
|
-
throw new TypeError('compareInstalled: `packageVersion` must be a non-empty string')
|
|
115
|
-
}
|
|
116
|
-
if (typeof exec !== 'function') {
|
|
117
|
-
throw new TypeError('compareInstalled: `exec` must be a function')
|
|
118
|
-
}
|
|
119
|
-
if (parseVersion(packageVersion) === null) {
|
|
120
|
-
throw new TypeError(`compareInstalled: \`packageVersion\` "${packageVersion}" is not parseable semver`)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
let raw
|
|
124
|
-
try {
|
|
125
|
-
raw = exec()
|
|
126
|
-
} catch (err) {
|
|
127
|
-
return {
|
|
128
|
-
action: 'install',
|
|
129
|
-
reason: `no PATH \`red\` binary detected (${err.message || 'exec failed'})`,
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const installed = parseVersion(typeof raw === 'string' ? raw : '')
|
|
134
|
-
if (installed === null) {
|
|
135
|
-
return {
|
|
136
|
-
action: 'install',
|
|
137
|
-
reason: `unparseable PATH \`red --version\` output (${truncate(String(raw))})`,
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const cmp = compareSemver(installed, packageVersion)
|
|
142
|
-
if (cmp < 0) {
|
|
143
|
-
return {
|
|
144
|
-
action: 'upgrade',
|
|
145
|
-
reason: `PATH \`red\` ${installed} is older than package ${packageVersion}`,
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (cmp > 0) {
|
|
149
|
-
return {
|
|
150
|
-
action: 'skip',
|
|
151
|
-
reason: `PATH \`red\` ${installed} is newer than package ${packageVersion}`,
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
action: 'skip',
|
|
156
|
-
reason: `PATH \`red\` already at ${installed}`,
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function truncate(s) {
|
|
161
|
-
const oneLine = s.replace(/\s+/g, ' ').trim()
|
|
162
|
-
return oneLine.length > 80 ? `${oneLine.slice(0, 77)}...` : oneLine
|
|
163
|
-
}
|