@reddb-io/client 1.7.0 → 1.8.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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Node-native streaming surface for the JS driver (PRD #759 / S11).
3
+ *
4
+ * Two transport-agnostic wrappers turn a low-level streaming *session*
5
+ * — supplied by whichever transport the connection uses (HTTP NDJSON or
6
+ * RedWire) — into idiomatic Node streams:
7
+ *
8
+ * - `RowReadable` — a `Readable` in object mode that also conforms to
9
+ * `AsyncIterable<Row>` (Readable already is one). Rows flow with
10
+ * natural backpressure via `read()` / `pause()` / `resume()`. The
11
+ * descriptor and the resumable cursor (when the transport surfaces
12
+ * them) arrive as `'descriptor'` / `'cursor'` events. A mid-stream
13
+ * `error` frame surfaces both as an `'error'` event and as a
14
+ * rejected `for await` iteration.
15
+ * - `RowWritable` — a `Writable` in object mode. Backpressure flows
16
+ * through `write()`'s return value and `'drain'`. `end()` signals
17
+ * end-of-stream; the server's terminal envelope resolves the
18
+ * `.completion()` promise.
19
+ *
20
+ * Both expose a uniform `cancel(reason?)` that terminates the underlying
21
+ * transport stream (a `StreamCancel` frame over RedWire, an
22
+ * `AbortController.abort()` over HTTP) and rejects anything pending.
23
+ *
24
+ * The transport session contracts these wrappers consume:
25
+ *
26
+ * read session (transport.streamSelect):
27
+ * { [Symbol.asyncIterator](): AsyncIterator<{type,value}>,
28
+ * cancel(reason): Promise<void> }
29
+ * where `type` is 'descriptor' | 'cursor' | 'row' | 'end'; the
30
+ * iterator throws a `RedDBError` when the server emits an error frame.
31
+ *
32
+ * write session (transport.streamInput):
33
+ * { write(row): Promise<void>, // resolves when accepted (backpressure)
34
+ * close(): Promise<EndEnvelope>, // send terminal, await server end
35
+ * cancel(reason): Promise<void> }
36
+ */
37
+
38
+ import { Readable, Writable, Transform } from 'node:stream'
39
+ import { RedDBError } from './protocol.js'
40
+ import { classifyNdjsonFrame } from './core/ndjson.js'
41
+
42
+ // `classifyNdjsonFrame` is a pure NDJSON helper that now lives in the
43
+ // transport-agnostic core; re-exported here so the historical
44
+ // `import { classifyNdjsonFrame } from './streaming.js'` path keeps working.
45
+ export { classifyNdjsonFrame }
46
+
47
+ function cancelError(reason) {
48
+ const suffix = typeof reason === 'string' && reason.length > 0 ? `: ${reason}` : ''
49
+ return new RedDBError('STREAM_CANCELLED', `stream cancelled${suffix}`)
50
+ }
51
+
52
+ /**
53
+ * `Readable` (object mode) over a transport read session. Also an
54
+ * `AsyncIterable<Row>` — `for await (const row of stream)` yields each
55
+ * row and exits cleanly on stream end.
56
+ */
57
+ export class RowReadable extends Readable {
58
+ /**
59
+ * @param {Promise<object>} sessionPromise resolves to a read session.
60
+ * @param {{ signal?: AbortSignal }} [opts]
61
+ */
62
+ constructor(sessionPromise, { signal } = {}) {
63
+ super({ objectMode: true })
64
+ this._sessionPromise = sessionPromise
65
+ this._session = null
66
+ this._iter = null
67
+ this._pumping = false
68
+ this._ended = false
69
+ this._cancelReason = undefined
70
+ /** Schema descriptor (HTTP NDJSON) once seen; null otherwise. */
71
+ this.descriptor = null
72
+ /** Resumable cursor control frame (#807) once seen; null otherwise. */
73
+ this.cursor = null
74
+ /** Terminal `end` envelope once the stream completes; null otherwise. */
75
+ this.endInfo = null
76
+ if (signal) {
77
+ if (signal.aborted) {
78
+ queueMicrotask(() => this.cancel(abortReason(signal)))
79
+ } else {
80
+ signal.addEventListener('abort', () => this.cancel(abortReason(signal)), { once: true })
81
+ }
82
+ }
83
+ }
84
+
85
+ async _resolveIter() {
86
+ if (this._iter) return this._iter
87
+ this._session = await this._sessionPromise
88
+ this._iter = this._session[Symbol.asyncIterator]()
89
+ return this._iter
90
+ }
91
+
92
+ _read() {
93
+ if (this._pumping) return
94
+ this._pumping = true
95
+ this._pump().then(
96
+ () => {
97
+ this._pumping = false
98
+ },
99
+ (err) => {
100
+ this._pumping = false
101
+ this.destroy(err instanceof Error ? err : new RedDBError('STREAM_ERROR', String(err)))
102
+ },
103
+ )
104
+ }
105
+
106
+ async _pump() {
107
+ const iter = await this._resolveIter()
108
+ // Loop until backpressure (push returned false) or completion. Non-row
109
+ // control frames (descriptor/cursor) are surfaced as events and do not
110
+ // count against the readable buffer, so we keep pulling after them.
111
+ for (;;) {
112
+ const { value: frame, done } = await iter.next()
113
+ if (done) {
114
+ this._ended = true
115
+ this.push(null)
116
+ return
117
+ }
118
+ if (frame.type === 'descriptor') {
119
+ this.descriptor = frame.value
120
+ this.emit('descriptor', frame.value)
121
+ continue
122
+ }
123
+ if (frame.type === 'cursor') {
124
+ this.cursor = frame.value
125
+ this.emit('cursor', frame.value)
126
+ continue
127
+ }
128
+ if (frame.type === 'end') {
129
+ this.endInfo = frame.value
130
+ this._ended = true
131
+ this.push(null)
132
+ return
133
+ }
134
+ // frame.type === 'row'
135
+ if (!this.push(frame.value)) {
136
+ return
137
+ }
138
+ }
139
+ }
140
+
141
+ _destroy(err, callback) {
142
+ // A stream that ended on its own terminal frame needs no cancel — only
143
+ // an early teardown (error or explicit cancel) signals the transport.
144
+ const session = this._ended ? null : this._session
145
+ const reason = this._cancelReason
146
+ Promise.resolve(session && session.cancel ? session.cancel(reason) : undefined)
147
+ .catch(() => {})
148
+ .finally(() => callback(err))
149
+ }
150
+
151
+ /**
152
+ * Terminate the stream. Sends a transport-level cancel and rejects any
153
+ * pending `for await` iteration with a `STREAM_CANCELLED` error.
154
+ * @param {string} [reason]
155
+ * @returns {Promise<void>}
156
+ */
157
+ cancel(reason) {
158
+ if (this.destroyed) return Promise.resolve()
159
+ this._cancelReason = reason
160
+ return new Promise((resolve) => {
161
+ this.once('close', resolve)
162
+ this.destroy(cancelError(reason))
163
+ })
164
+ }
165
+ }
166
+
167
+ /**
168
+ * `Writable` (object mode) over a transport write session. End-of-stream
169
+ * is `end()`; the server's terminal envelope resolves `.completion()`.
170
+ */
171
+ export class RowWritable extends Writable {
172
+ /**
173
+ * @param {Promise<object>} sessionPromise resolves to a write session.
174
+ * @param {{ signal?: AbortSignal }} [opts]
175
+ */
176
+ constructor(sessionPromise, { signal } = {}) {
177
+ super({ objectMode: true })
178
+ this._sessionPromise = sessionPromise
179
+ this._session = null
180
+ this._finished = false
181
+ this._cancelReason = undefined
182
+ this._completion = null
183
+ this._completionPromise = new Promise((resolve, reject) => {
184
+ this._completion = { resolve, reject }
185
+ })
186
+ // Don't crash the process if nobody attaches to completion() and the
187
+ // stream errors — the 'error' event already carries the failure.
188
+ this._completionPromise.catch(() => {})
189
+ if (signal) {
190
+ if (signal.aborted) {
191
+ queueMicrotask(() => this.cancel(abortReason(signal)))
192
+ } else {
193
+ signal.addEventListener('abort', () => this.cancel(abortReason(signal)), { once: true })
194
+ }
195
+ }
196
+ }
197
+
198
+ async _resolveSession() {
199
+ if (!this._session) this._session = await this._sessionPromise
200
+ return this._session
201
+ }
202
+
203
+ _write(row, _enc, callback) {
204
+ this._resolveSession()
205
+ .then((session) => session.write(row))
206
+ .then(() => callback(), (err) => callback(err))
207
+ }
208
+
209
+ _final(callback) {
210
+ this._resolveSession()
211
+ .then((session) => session.close())
212
+ .then(
213
+ (end) => {
214
+ this._finished = true
215
+ this._completion.resolve(end)
216
+ callback()
217
+ },
218
+ (err) => {
219
+ this._completion.reject(err)
220
+ callback(err)
221
+ },
222
+ )
223
+ }
224
+
225
+ _destroy(err, callback) {
226
+ if (err) this._completion.reject(err)
227
+ const session = this._finished ? null : this._session
228
+ const reason = this._cancelReason
229
+ Promise.resolve(session && session.cancel ? session.cancel(reason) : undefined)
230
+ .catch(() => {})
231
+ .finally(() => callback(err))
232
+ }
233
+
234
+ /**
235
+ * Resolves with the server's terminal `end` envelope once the stream
236
+ * finishes successfully; rejects if the stream errors or is cancelled.
237
+ * @returns {Promise<object>}
238
+ */
239
+ completion() {
240
+ return this._completionPromise
241
+ }
242
+
243
+ /**
244
+ * Terminate the stream without flushing the remaining rows. Rejects
245
+ * `.completion()` with a `STREAM_CANCELLED` error.
246
+ * @param {string} [reason]
247
+ * @returns {Promise<void>}
248
+ */
249
+ cancel(reason) {
250
+ if (this.destroyed) return Promise.resolve()
251
+ this._cancelReason = reason
252
+ return new Promise((resolve) => {
253
+ this.once('close', resolve)
254
+ this.destroy(cancelError(reason))
255
+ })
256
+ }
257
+ }
258
+
259
+ function abortReason(signal) {
260
+ const reason = signal?.reason
261
+ if (typeof reason === 'string') return reason
262
+ if (reason && typeof reason.message === 'string') return reason.message
263
+ return 'aborted'
264
+ }
265
+
266
+ /**
267
+ * A `Transform` that splits an NDJSON byte/text stream into parsed row
268
+ * objects, ready to pipe into `table.inputStream()`:
269
+ *
270
+ * fs.createReadStream('rows.ndjson').pipe(splitNdjson()).pipe(table.inputStream())
271
+ *
272
+ * Each non-empty line is `JSON.parse`d; a `{ "row": {...} }` envelope is
273
+ * unwrapped to its inner object so files written by the streaming reader
274
+ * round-trip, while bare-object lines pass through untouched.
275
+ * @returns {Transform}
276
+ */
277
+ export function splitNdjson() {
278
+ let buffer = ''
279
+ const parseLine = (line, push, callback) => {
280
+ const trimmed = line.trim()
281
+ if (trimmed.length === 0) return true
282
+ let parsed
283
+ try {
284
+ parsed = JSON.parse(trimmed)
285
+ } catch (err) {
286
+ callback(new RedDBError('NDJSON_PARSE_ERROR', `invalid NDJSON line: ${err.message}`))
287
+ return false
288
+ }
289
+ const row = parsed && typeof parsed === 'object' && 'row' in parsed ? parsed.row : parsed
290
+ push(row)
291
+ return true
292
+ }
293
+ return new Transform({
294
+ readableObjectMode: true,
295
+ writableObjectMode: false,
296
+ transform(chunk, _enc, callback) {
297
+ buffer += chunk.toString('utf8')
298
+ let nl
299
+ while ((nl = buffer.indexOf('\n')) !== -1) {
300
+ const line = buffer.slice(0, nl)
301
+ buffer = buffer.slice(nl + 1)
302
+ if (!parseLine(line, (row) => this.push(row), callback)) return
303
+ }
304
+ callback()
305
+ },
306
+ flush(callback) {
307
+ if (parseLine(buffer, (row) => this.push(row), callback)) callback()
308
+ },
309
+ })
310
+ }
311
+
312
+ /**
313
+ * Build a `RowReadable` from a connection's transport client.
314
+ * @param {object} client transport exposing `streamSelect`.
315
+ * @param {string} sql read-only SELECT to stream.
316
+ * @param {{ signal?: AbortSignal, cursor?: string }} [opts]
317
+ * @returns {RowReadable}
318
+ */
319
+ export function createSelectStream(client, sql, opts = {}) {
320
+ if (typeof client.streamSelect !== 'function') {
321
+ throw new RedDBError(
322
+ 'STREAMING_UNSUPPORTED',
323
+ 'the active transport does not support streaming reads (use red:// or http(s)://)',
324
+ )
325
+ }
326
+ if (opts.cursor == null && (typeof sql !== 'string' || sql.trim().length === 0)) {
327
+ throw new RedDBError('INVALID_STREAM_QUERY', 'stream() requires a non-empty SQL string')
328
+ }
329
+ const sessionPromise = Promise.resolve().then(() =>
330
+ client.streamSelect({ sql, cursor: opts.cursor, signal: opts.signal }),
331
+ )
332
+ return new RowReadable(sessionPromise, { signal: opts.signal })
333
+ }
334
+
335
+ /**
336
+ * Build a `RowWritable` from a connection's transport client.
337
+ * @param {object} client transport exposing `streamInput`.
338
+ * @param {string} target table to ingest into.
339
+ * @param {{ signal?: AbortSignal, columns?: string[] }} [opts] `columns`
340
+ * fixes the ingest column set; when omitted it is inferred from the
341
+ * keys of the first row written.
342
+ * @returns {RowWritable}
343
+ */
344
+ export function createInputStream(client, target, opts = {}) {
345
+ if (typeof client.streamInput !== 'function') {
346
+ throw new RedDBError(
347
+ 'STREAMING_UNSUPPORTED',
348
+ 'the active transport does not support streaming writes (use red:// or http(s)://)',
349
+ )
350
+ }
351
+ if (typeof target !== 'string' || target.trim().length === 0) {
352
+ throw new RedDBError('INVALID_STREAM_TARGET', 'inputStream() requires a non-empty target table')
353
+ }
354
+ const sessionPromise = Promise.resolve().then(() =>
355
+ client.streamInput({
356
+ target,
357
+ columns: opts.columns,
358
+ signal: opts.signal,
359
+ }),
360
+ )
361
+ return new RowWritable(sessionPromise, { signal: opts.signal })
362
+ }
package/src/url.js CHANGED
@@ -1,271 +1,12 @@
1
1
  /**
2
- * `red://` connection-string parser.
3
- *
4
- * One URL covers every transport RedDB speaks:
5
- *
6
- * red:// embedded in-memory
7
- * red:///abs/path/data.rdb embedded persistent
8
- * red://user:pass@host:5050 remote, default proto=red (wire)
9
- * red://host:8080?proto=https remote HTTPS
10
- * red://host:5432?proto=pg PostgreSQL wire
11
- * red://host:5055?proto=grpc&token=sk-abc remote gRPC w/ bearer
12
- * red://host:8080?proto=https&apiKey=ak-xyz remote HTTPS w/ api key
13
- *
14
- * Backwards-compat: legacy `memory://`, `file://`, `grpc://` URLs
15
- * still work via `parseLegacyUrl`. New code should prefer `red://`
16
- * because it carries auth + protocol selection in one place.
2
+ * Compatibility shim. The `red://` connection-string parser now lives in
3
+ * the transport-agnostic core (`core/url.js`); this file keeps the
4
+ * historical `./url.js` import path working.
17
5
  */
18
6
 
19
- import { RedDBError } from './protocol.js'
20
-
21
- /**
22
- * @typedef {object} ParsedUri
23
- * @property {'embedded' | 'http' | 'https' | 'red' | 'reds' | 'grpc' | 'grpcs' | 'pg'} kind
24
- * @property {string} [host]
25
- * @property {number} [port]
26
- * @property {string} [path] // for embedded `file://`-equivalent
27
- * @property {string} [username]
28
- * @property {string} [password]
29
- * @property {string} [token]
30
- * @property {string} [apiKey]
31
- * @property {string} [loginUrl] // explicit override for login flow
32
- * @property {URLSearchParams} [params] // remaining query params
33
- * @property {string} originalUri
34
- */
35
-
36
- /**
37
- * Parse any URI string into a normalised `ParsedUri`.
38
- * Accepts `red://`, `memory://`, `file://`, `grpc://` (the latter
39
- * three for backwards compat).
40
- *
41
- * @param {string} uri
42
- * @returns {ParsedUri}
43
- */
44
- export function parseUri(uri) {
45
- if (typeof uri !== 'string' || uri.length === 0) {
46
- throw new TypeError(
47
- "connect() requires a URI string (e.g. 'red://localhost:5050' or 'red:///data.rdb')",
48
- )
49
- }
50
- if (uri.startsWith('red://') || uri === 'red:' || uri === 'red:/') {
51
- return parseRedUrl(uri)
52
- }
53
- return parseLegacyUrl(uri)
54
- }
55
-
56
- /**
57
- * Parse a `red://` URL.
58
- *
59
- * Authority shape: `[user[:pass]@]host[:port]`
60
- * Path: optional, used as filesystem path when `host` is absent or
61
- * is the special token `localhost-embedded` (rare).
62
- * Query: `proto`, `token`, `apiKey`, `loginUrl`.
63
- */
64
- export function parseRedUrl(uri) {
65
- // The host might be missing (`red:///path`), the URL constructor
66
- // requires *something* there. Re-write to a parse-friendly shape:
67
- // - `red:///x` → `red://embedded.local/x` (embedded with path)
68
- // - `red://memory` → `red://embedded.local` (embedded in-memory)
69
- // - `red://` → `red://embedded.local` (embedded in-memory)
70
- let normalised = uri
71
- if (uri === 'red:' || uri === 'red:/' || uri === 'red://') {
72
- normalised = 'red://embedded.local'
73
- } else if (uri.startsWith('red:///')) {
74
- normalised = `red://embedded.local${uri.slice('red://'.length)}`
75
- } else if (
76
- uri === 'red://memory'
77
- || uri === 'red://memory/'
78
- || uri === 'red://:memory'
79
- || uri === 'red://:memory:' // SQLite-style ":memory:" alias
80
- ) {
81
- normalised = 'red://embedded.local'
82
- }
83
-
84
- let parsed
85
- try {
86
- parsed = new URL(normalised)
87
- } catch (err) {
88
- throw new RedDBError('UNPARSEABLE_URI', `failed to parse '${uri}': ${err.message}`)
89
- }
90
-
91
- const params = parsed.searchParams
92
- const proto = (params.get('proto') || '').toLowerCase()
93
- const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : ''
94
-
95
- // Embedded: special host, OR `proto=embedded`, OR no proto + has path
96
- // and the user clearly meant a file path (red:///abs/path).
97
- if (parsed.hostname === 'embedded.local') {
98
- if (path) {
99
- return {
100
- kind: 'embedded',
101
- path,
102
- params,
103
- originalUri: uri,
104
- }
105
- }
106
- return {
107
- kind: 'embedded',
108
- params,
109
- originalUri: uri,
110
- }
111
- }
112
-
113
- // Remote — default proto is red (wire).
114
- const kind = resolveKind(proto)
115
- const port = parsed.port ? Number(parsed.port) : defaultPortFor(kind)
116
- const username = parsed.username ? decodeURIComponent(parsed.username) : undefined
117
- const password = parsed.password ? decodeURIComponent(parsed.password) : undefined
118
-
119
- return {
120
- kind,
121
- host: parsed.hostname,
122
- port,
123
- path: path || undefined,
124
- username,
125
- password,
126
- token: params.get('token') ?? undefined,
127
- apiKey: params.get('apiKey') ?? params.get('api_key') ?? undefined,
128
- loginUrl: params.get('loginUrl') ?? params.get('login_url') ?? undefined,
129
- params,
130
- originalUri: uri,
131
- }
132
- }
133
-
134
- /**
135
- * Backwards-compat parser for the legacy URL shapes the driver
136
- * accepted before `red://` existed. Returns the same `ParsedUri`
137
- * shape so downstream code is uniform.
138
- */
139
- export function parseLegacyUrl(uri) {
140
- if (uri === 'memory://' || uri === 'memory:') {
141
- return { kind: 'embedded', originalUri: uri }
142
- }
143
- if (uri.startsWith('file://')) {
144
- const path = uri.slice('file://'.length)
145
- if (!path) {
146
- throw new TypeError(`invalid file:// URI: missing path in '${uri}'`)
147
- }
148
- return { kind: 'embedded', path, originalUri: uri }
149
- }
150
- if (
151
- uri.startsWith('grpc://')
152
- || uri.startsWith('grpcs://')
153
- || uri.startsWith('reds://')
154
- ) {
155
- const scheme = uri.split('://', 1)[0]
156
- const stripped = uri.slice(`${scheme}://`.length)
157
- const [hostPort] = stripped.split(/[/?]/, 1)
158
- const [host, portStr] = hostPort.split(':')
159
- if (!host) {
160
- throw new TypeError(`invalid ${scheme}:// URI: missing host in '${uri}'`)
161
- }
162
- const legacyKind = scheme === 'reds' ? 'reds' : scheme === 'grpcs' ? 'grpcs' : scheme === 'grpc' ? 'grpc' : 'red'
163
- return {
164
- kind: legacyKind,
165
- host,
166
- port: portStr ? Number(portStr) : defaultPortFor(legacyKind),
167
- originalUri: uri,
168
- }
169
- }
170
- if (uri.startsWith('http://') || uri.startsWith('https://')) {
171
- let parsed
172
- try {
173
- parsed = new URL(uri)
174
- } catch (err) {
175
- throw new RedDBError('UNPARSEABLE_URI', `failed to parse '${uri}': ${err.message}`)
176
- }
177
- return {
178
- kind: parsed.protocol === 'https:' ? 'https' : 'http',
179
- host: parsed.hostname,
180
- port: parsed.port ? Number(parsed.port) : defaultPortFor(parsed.protocol === 'https:' ? 'https' : 'http'),
181
- path: parsed.pathname !== '/' ? parsed.pathname : undefined,
182
- username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
183
- password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
184
- token: parsed.searchParams.get('token') ?? undefined,
185
- apiKey: parsed.searchParams.get('apiKey') ?? undefined,
186
- params: parsed.searchParams,
187
- originalUri: uri,
188
- }
189
- }
190
- throw new RedDBError(
191
- 'UNSUPPORTED_SCHEME',
192
- `unsupported URI: '${uri}'. Use 'red://...' or one of memory://, file://, grpc://, http(s)://`,
193
- )
194
- }
195
-
196
- function resolveKind(protoQueryParam) {
197
- switch (protoQueryParam) {
198
- case '':
199
- case 'red':
200
- return 'red'
201
- case 'reds':
202
- return 'reds'
203
- case 'grpc':
204
- return 'grpc'
205
- case 'grpcs':
206
- return 'grpcs'
207
- case 'http':
208
- return 'http'
209
- case 'https':
210
- return 'https'
211
- case 'pg':
212
- case 'postgres':
213
- case 'postgresql':
214
- return 'pg'
215
- default:
216
- throw new RedDBError(
217
- 'UNSUPPORTED_PROTO',
218
- `unknown proto='${protoQueryParam}'. Supported: red | reds | grpc | grpcs | http | https | pg`,
219
- )
220
- }
221
- }
222
-
223
- function defaultPortFor(kind) {
224
- switch (kind) {
225
- case 'http':
226
- return 8080
227
- case 'https':
228
- return 8443
229
- case 'red':
230
- case 'reds':
231
- case 'redwire':
232
- return 5050
233
- case 'grpc':
234
- return 5055
235
- case 'grpcs':
236
- return 5056
237
- case 'pg':
238
- case 'postgres':
239
- case 'postgresql':
240
- return 5432
241
- default:
242
- return undefined
243
- }
244
- }
245
-
246
- /**
247
- * Derive the HTTP login URL (`/auth/login`) from a parsed URI.
248
- * Used by the auto-login flow when the user supplies `username:password@`
249
- * but not an explicit `loginUrl`.
250
- *
251
- * Strategy: if proto is already http/https, just append `/auth/login`.
252
- * For grpc/grpcs/pg, default to https://host:443 — operators that
253
- * don't want that should pass `loginUrl=` explicitly.
254
- */
255
- export function deriveLoginUrl(parsed) {
256
- if (parsed.loginUrl) return parsed.loginUrl
257
- if (!parsed.host) {
258
- throw new RedDBError(
259
- 'AUTH_LOGIN_NEEDS_HOST',
260
- 'cannot derive loginUrl without a host; pass it explicitly via loginUrl=...',
261
- )
262
- }
263
- if (parsed.kind === 'http' || parsed.kind === 'https') {
264
- const scheme = parsed.kind
265
- const port = parsed.port ?? defaultPortFor(parsed.kind)
266
- return `${scheme}://${parsed.host}:${port}/auth/login`
267
- }
268
- // Non-HTTP transports — default to HTTPS on 443 unless the user
269
- // tells us otherwise.
270
- return `https://${parsed.host}/auth/login`
271
- }
7
+ export {
8
+ parseUri,
9
+ parseRedUrl,
10
+ parseLegacyUrl,
11
+ deriveLoginUrl,
12
+ } from './core/url.js'