@reddb-io/client 1.0.1
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/LICENSE +661 -0
- package/README.md +97 -0
- package/index.d.ts +223 -0
- package/package.json +49 -0
- package/postinstall.js +97 -0
- package/src/cache.js +137 -0
- package/src/config.js +66 -0
- package/src/embedded-rejection.js +90 -0
- package/src/http.js +200 -0
- package/src/index.js +336 -0
- package/src/internal/asset-fetcher/asset-name.js +37 -0
- package/src/internal/asset-fetcher/checksum.js +23 -0
- package/src/internal/asset-fetcher/download.js +89 -0
- package/src/internal/asset-fetcher/index.js +52 -0
- package/src/kv.js +70 -0
- package/src/protocol.js +157 -0
- package/src/redwire.js +723 -0
- package/src/url.js +271 -0
- package/src/vault.js +58 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { request as httpsRequest } from 'node:https'
|
|
2
|
+
import { request as httpRequest } from 'node:http'
|
|
3
|
+
|
|
4
|
+
const MAX_REDIRECTS = 5
|
|
5
|
+
|
|
6
|
+
export class AssetNotFoundError extends Error {
|
|
7
|
+
constructor(url) {
|
|
8
|
+
super(`asset not found (HTTP 404) at ${url}`)
|
|
9
|
+
this.name = 'AssetNotFoundError'
|
|
10
|
+
this.code = 'ASSET_NOT_FOUND'
|
|
11
|
+
this.url = url
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class HttpError extends Error {
|
|
16
|
+
constructor(status, url) {
|
|
17
|
+
super(`HTTP ${status} fetching ${url}`)
|
|
18
|
+
this.name = 'HttpError'
|
|
19
|
+
this.code = 'HTTP_ERROR'
|
|
20
|
+
this.status = status
|
|
21
|
+
this.url = url
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TooManyRedirectsError extends Error {
|
|
26
|
+
constructor(url) {
|
|
27
|
+
super(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`)
|
|
28
|
+
this.name = 'TooManyRedirectsError'
|
|
29
|
+
this.code = 'TOO_MANY_REDIRECTS'
|
|
30
|
+
this.url = url
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pickRequest(url) {
|
|
35
|
+
return url.startsWith('http://') ? httpRequest : httpsRequest
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveLocation(currentUrl, location) {
|
|
39
|
+
if (/^https?:\/\//i.test(location)) return location
|
|
40
|
+
return new URL(location, currentUrl).toString()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {}, depth = 0) {
|
|
44
|
+
const startUrl = originalUrl || url
|
|
45
|
+
if (depth > MAX_REDIRECTS) {
|
|
46
|
+
return Promise.reject(new TooManyRedirectsError(startUrl))
|
|
47
|
+
}
|
|
48
|
+
const request = pickRequest(url)
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const req = request(
|
|
51
|
+
url,
|
|
52
|
+
{
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
'User-Agent': userAgent || 'reddb-internal-asset-fetcher',
|
|
56
|
+
Accept: 'application/octet-stream',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
(res) => {
|
|
60
|
+
const status = res.statusCode || 0
|
|
61
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
62
|
+
res.resume()
|
|
63
|
+
const next = resolveLocation(url, res.headers.location)
|
|
64
|
+
downloadFollowingRedirects(next, { userAgent, originalUrl: startUrl }, depth + 1).then(
|
|
65
|
+
resolve,
|
|
66
|
+
reject,
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (status === 404) {
|
|
71
|
+
res.resume()
|
|
72
|
+
reject(new AssetNotFoundError(startUrl))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (status < 200 || status >= 300) {
|
|
76
|
+
res.resume()
|
|
77
|
+
reject(new HttpError(status, url))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const chunks = []
|
|
81
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
82
|
+
res.on('end', () => resolve(Buffer.concat(chunks)))
|
|
83
|
+
res.on('error', reject)
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
req.on('error', reject)
|
|
87
|
+
req.end()
|
|
88
|
+
})
|
|
89
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { verifySha256 } from './checksum.js'
|
|
30
|
+
|
|
31
|
+
export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
|
|
32
|
+
if (typeof repo !== 'string' || repo === '') {
|
|
33
|
+
throw new TypeError('fetchReleaseAsset: `repo` must be a non-empty string (e.g. "reddb-io/reddb")')
|
|
34
|
+
}
|
|
35
|
+
if (typeof tag !== 'string' || tag === '') {
|
|
36
|
+
throw new TypeError('fetchReleaseAsset: `tag` must be a non-empty string (e.g. "v0.2.9")')
|
|
37
|
+
}
|
|
38
|
+
if (typeof platform !== 'string' || platform === '') {
|
|
39
|
+
throw new TypeError('fetchReleaseAsset: `platform` must be a non-empty string')
|
|
40
|
+
}
|
|
41
|
+
if (typeof arch !== 'string' || arch === '') {
|
|
42
|
+
throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const assetName = composeAssetName({ platform, arch, binName })
|
|
46
|
+
const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`
|
|
47
|
+
const body = await downloadFollowingRedirects(url)
|
|
48
|
+
if (sha256) {
|
|
49
|
+
verifySha256(body, sha256)
|
|
50
|
+
}
|
|
51
|
+
return body
|
|
52
|
+
}
|
package/src/kv.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RedDBError } from './protocol.js'
|
|
2
|
+
|
|
3
|
+
export class KvClient {
|
|
4
|
+
constructor(client, collection = 'kv_default') {
|
|
5
|
+
this.client = client
|
|
6
|
+
this.collection = collection
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
put(key, value, options = {}) {
|
|
10
|
+
const collection = options.collection ?? this.collection
|
|
11
|
+
const tags = Array.isArray(options.tags) && options.tags.length > 0
|
|
12
|
+
? ` TAGS [${options.tags.map(kvTagLiteral).join(', ')}]`
|
|
13
|
+
: ''
|
|
14
|
+
const expire = options.expireMs != null ? ` EXPIRE ${Number(options.expireMs)} ms` : ''
|
|
15
|
+
return this.client.call('query', {
|
|
16
|
+
sql: `KV PUT ${kvPath(collection, key)} = ${kvValueLiteral(value)}${expire}${tags}`,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async invalidateTags(tags, options = {}) {
|
|
21
|
+
const collection = options.collection ?? this.collection
|
|
22
|
+
const result = await this.client.call('query', {
|
|
23
|
+
sql: `INVALIDATE TAGS [${tags.map(kvTagLiteral).join(', ')}] FROM ${kvIdentifier(collection)}`,
|
|
24
|
+
})
|
|
25
|
+
return result.affected ?? result.affected_rows ?? result.rows?.[0]?.invalidated ?? 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async *watch(key, options = {}) {
|
|
29
|
+
if (!this.client.baseUrl) {
|
|
30
|
+
throw new RedDBError('UNSUPPORTED_TRANSPORT', 'kv.watch requires the HTTP transport')
|
|
31
|
+
}
|
|
32
|
+
const collection = options.collection ?? this.collection
|
|
33
|
+
const params = new URLSearchParams()
|
|
34
|
+
if (options.sinceLsn != null) params.set('since_lsn', String(options.sinceLsn))
|
|
35
|
+
if (options.limit != null) params.set('limit', String(options.limit))
|
|
36
|
+
const suffix = params.toString() ? `?${params}` : ''
|
|
37
|
+
const url = `${this.client.baseUrl}/collections/${encodeURIComponent(collection)}/kv/${encodeURIComponent(String(key))}/watch${suffix}`
|
|
38
|
+
const response = await fetch(url, this.client.attachAuth({ method: 'GET' }))
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new RedDBError('HTTP_ERROR', `kv.watch failed with HTTP ${response.status}`)
|
|
41
|
+
}
|
|
42
|
+
const text = await response.text()
|
|
43
|
+
for (const block of text.split('\n\n')) {
|
|
44
|
+
const line = block.split('\n').find((entry) => entry.startsWith('data: '))
|
|
45
|
+
if (line) yield JSON.parse(line.slice(6))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
watchPrefix(prefix, options = {}) {
|
|
50
|
+
return this.watch(`${prefix}.*`, options)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function kvPath(collection, key) {
|
|
55
|
+
return `${kvIdentifier(collection)}.${kvIdentifier(key)}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function kvIdentifier(value) {
|
|
59
|
+
return String(value).replace(/[^A-Za-z0-9_]/g, '_')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function kvValueLiteral(value) {
|
|
63
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
64
|
+
if (value == null) return 'NULL'
|
|
65
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function kvTagLiteral(value) {
|
|
69
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
70
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 line-delimited client.
|
|
3
|
+
*
|
|
4
|
+
* Maintains a map of pending requests keyed by id. Reads stdout one
|
|
5
|
+
* line at a time, parses each line as a response envelope, looks up
|
|
6
|
+
* the pending promise by id and resolves/rejects it.
|
|
7
|
+
*
|
|
8
|
+
* Spec: PLAN_DRIVERS.md, "Spec do protocolo stdio".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const NEWLINE = 0x0a // '\n'
|
|
12
|
+
const encoder = new TextEncoder()
|
|
13
|
+
const decoder = new TextDecoder('utf-8')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* RedDB-shaped error. Drivers in other languages should expose an
|
|
17
|
+
* equivalent class with the same `code` field.
|
|
18
|
+
*/
|
|
19
|
+
export class RedDBError extends Error {
|
|
20
|
+
constructor(code, message, data) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'RedDBError'
|
|
23
|
+
this.code = code
|
|
24
|
+
this.data = data ?? null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RpcClient {
|
|
29
|
+
/** @param {import('./spawn.js').RedProcess} child */
|
|
30
|
+
constructor(child) {
|
|
31
|
+
this.child = child
|
|
32
|
+
this.nextId = 1
|
|
33
|
+
/** @type {Map<number|string, { resolve: Function, reject: Function }>} */
|
|
34
|
+
this.pending = new Map()
|
|
35
|
+
this.closed = false
|
|
36
|
+
this.closeReason = null
|
|
37
|
+
this.readerPromise = this.#readLoop()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Send a JSON-RPC 2.0 request and resolve with the result, or reject
|
|
42
|
+
* with a `RedDBError` if the server returned an error envelope.
|
|
43
|
+
*/
|
|
44
|
+
call(method, params = {}) {
|
|
45
|
+
if (this.closed) {
|
|
46
|
+
return Promise.reject(
|
|
47
|
+
new RedDBError('CLIENT_CLOSED', `client is closed: ${this.closeReason ?? 'unknown'}`),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const id = this.nextId++
|
|
51
|
+
const envelope = JSON.stringify({ jsonrpc: '2.0', id, method, params })
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
this.pending.set(id, { resolve, reject })
|
|
54
|
+
this.child.stdin.write(encoder.encode(envelope + '\n')).catch((err) => {
|
|
55
|
+
this.pending.delete(id)
|
|
56
|
+
reject(err)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drain pending requests, send `close`, wait for the binary to exit. */
|
|
62
|
+
async close() {
|
|
63
|
+
if (this.closed) return
|
|
64
|
+
try {
|
|
65
|
+
await this.call('close', {})
|
|
66
|
+
} catch {
|
|
67
|
+
// best effort — server may already be gone
|
|
68
|
+
}
|
|
69
|
+
this.#shutdown('close requested')
|
|
70
|
+
try {
|
|
71
|
+
this.child.stdin.end()
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
await this.child.wait()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
// Internal: stdout reader loop
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async #readLoop() {
|
|
83
|
+
let buffer = new Uint8Array(0)
|
|
84
|
+
try {
|
|
85
|
+
for await (const chunk of this.child.stdout) {
|
|
86
|
+
const merged = new Uint8Array(buffer.length + chunk.length)
|
|
87
|
+
merged.set(buffer, 0)
|
|
88
|
+
merged.set(chunk, buffer.length)
|
|
89
|
+
buffer = merged
|
|
90
|
+
|
|
91
|
+
// Split on \n, dispatch each complete line.
|
|
92
|
+
let start = 0
|
|
93
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
94
|
+
if (buffer[i] === NEWLINE) {
|
|
95
|
+
const lineBytes = buffer.subarray(start, i)
|
|
96
|
+
this.#dispatchLine(decoder.decode(lineBytes))
|
|
97
|
+
start = i + 1
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (start > 0) {
|
|
101
|
+
buffer = buffer.subarray(start)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
this.#shutdown(`stdout reader error: ${err.message}`)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
// EOF — server exited.
|
|
109
|
+
this.#shutdown('server stdout closed')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#dispatchLine(line) {
|
|
113
|
+
if (!line.trim()) return
|
|
114
|
+
let envelope
|
|
115
|
+
try {
|
|
116
|
+
envelope = JSON.parse(line)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// Malformed line from the server is fatal to the protocol —
|
|
119
|
+
// we cannot map it to any pending request.
|
|
120
|
+
this.#shutdown(`malformed server response: ${err.message}`)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const id = envelope.id
|
|
124
|
+
if (id === null || id === undefined) {
|
|
125
|
+
// No id → cannot route. Treat as protocol violation.
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const pending = this.pending.get(id)
|
|
129
|
+
if (!pending) {
|
|
130
|
+
// Unknown id — server bug or duplicate response. Ignore.
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
this.pending.delete(id)
|
|
134
|
+
if (envelope.error) {
|
|
135
|
+
pending.reject(
|
|
136
|
+
new RedDBError(
|
|
137
|
+
envelope.error.code ?? 'UNKNOWN',
|
|
138
|
+
envelope.error.message ?? 'unknown error',
|
|
139
|
+
envelope.error.data,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
} else {
|
|
143
|
+
pending.resolve(envelope.result)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#shutdown(reason) {
|
|
148
|
+
if (this.closed) return
|
|
149
|
+
this.closed = true
|
|
150
|
+
this.closeReason = reason
|
|
151
|
+
const err = new RedDBError('CLIENT_CLOSED', reason)
|
|
152
|
+
for (const { reject } of this.pending.values()) {
|
|
153
|
+
reject(err)
|
|
154
|
+
}
|
|
155
|
+
this.pending.clear()
|
|
156
|
+
}
|
|
157
|
+
}
|