@reddb-io/ui 0.1.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 +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- package/src/lib/theme.svelte.ts +63 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Per-query-tab state — keyed on tab id from the tabs store.
|
|
2
|
+
//
|
|
3
|
+
// Each query tab carries its own editor text, last result, error, and
|
|
4
|
+
// running flag. Stored separately from the tabs store so the Tab type
|
|
5
|
+
// stays a thin identity record and switching tabs preserves state.
|
|
6
|
+
|
|
7
|
+
import type { QueryResult } from '#reddb'
|
|
8
|
+
|
|
9
|
+
export interface QueryTabState {
|
|
10
|
+
sql: string
|
|
11
|
+
result: QueryResult | null
|
|
12
|
+
error: string | null
|
|
13
|
+
running: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type QueryExecutor = (sql: string) => Promise<QueryResult>
|
|
17
|
+
|
|
18
|
+
function emptyState(): QueryTabState {
|
|
19
|
+
return { sql: '', result: null, error: null, running: false }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class QueryTabsStore {
|
|
23
|
+
/** Per-tab state keyed by tab id. */
|
|
24
|
+
states = $state<Record<string, QueryTabState>>({})
|
|
25
|
+
/** Monotonic counter for "Query N" labels — survives close so numbers don't reuse. */
|
|
26
|
+
private counter = 0
|
|
27
|
+
private executor: QueryExecutor | null = null
|
|
28
|
+
|
|
29
|
+
setExecutor(executor: QueryExecutor | null): void {
|
|
30
|
+
this.executor = executor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
nextLabel(): string {
|
|
34
|
+
this.counter += 1
|
|
35
|
+
return `Query ${this.counter}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ensure(id: string): QueryTabState {
|
|
39
|
+
if (!this.states[id]) this.states[id] = emptyState()
|
|
40
|
+
return this.states[id]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setSql(id: string, sql: string): void {
|
|
44
|
+
const s = this.ensure(id)
|
|
45
|
+
s.sql = sql
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get(id: string): QueryTabState | undefined {
|
|
49
|
+
return this.states[id]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
has(id: string): boolean {
|
|
53
|
+
return this.states[id] !== undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
remove(id: string): void {
|
|
57
|
+
delete this.states[id]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns true when the tab has user-entered text (after trimming) that
|
|
62
|
+
* was not produced by a successful run. Used to gate the close
|
|
63
|
+
* confirmation.
|
|
64
|
+
*/
|
|
65
|
+
isDirty(id: string): boolean {
|
|
66
|
+
const s = this.states[id]
|
|
67
|
+
if (!s) return false
|
|
68
|
+
return s.sql.trim().length > 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the tab's current sql through the wired executor. Updates result,
|
|
73
|
+
* error, running, in place. Safe to call concurrently per tab — the
|
|
74
|
+
* running flag guards against re-entry.
|
|
75
|
+
*/
|
|
76
|
+
async run(id: string): Promise<void> {
|
|
77
|
+
const s = this.ensure(id)
|
|
78
|
+
if (s.running) return
|
|
79
|
+
if (!this.executor) {
|
|
80
|
+
s.error = 'No connection — connect to a reddb instance first.'
|
|
81
|
+
s.result = null
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const sql = s.sql.trim()
|
|
85
|
+
if (!sql) {
|
|
86
|
+
s.error = 'Empty query.'
|
|
87
|
+
s.result = null
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
s.running = true
|
|
91
|
+
s.error = null
|
|
92
|
+
try {
|
|
93
|
+
const r = await this.executor(sql)
|
|
94
|
+
s.result = r
|
|
95
|
+
s.error = r.ok ? null : (r.error ?? 'Query failed.')
|
|
96
|
+
} catch (e) {
|
|
97
|
+
s.error = (e as Error).message
|
|
98
|
+
s.result = null
|
|
99
|
+
} finally {
|
|
100
|
+
s.running = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Test-only reset. */
|
|
105
|
+
clear(): void {
|
|
106
|
+
this.states = {}
|
|
107
|
+
this.counter = 0
|
|
108
|
+
this.executor = null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const queryTabs = new QueryTabsStore()
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Derive a tab title from the SQL text. Defaults to `fallback` (e.g.
|
|
116
|
+
* "Query 3") when the SQL is empty or the SELECT clause is missing /
|
|
117
|
+
* trivial.
|
|
118
|
+
*
|
|
119
|
+
* Kept pure & exported for unit tests.
|
|
120
|
+
*/
|
|
121
|
+
export function deriveQueryLabel(sql: string, fallback: string, max = 24): string {
|
|
122
|
+
const trimmed = sql.trim()
|
|
123
|
+
if (!trimmed) return fallback
|
|
124
|
+
const match = trimmed.match(/^\s*select\s+([\s\S]+?)\s+from\b/i)
|
|
125
|
+
if (!match) return fallback
|
|
126
|
+
const clause = match[1].replace(/\s+/g, ' ').trim()
|
|
127
|
+
if (!clause || clause === '*') return fallback
|
|
128
|
+
if (clause.length <= max) return clause
|
|
129
|
+
return clause.slice(0, max - 1).trimEnd() + '…'
|
|
130
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Open Contract URL pre-configuration. A Surface can seed the loaded Core with
|
|
2
|
+
// a browser-reachable reddb endpoint (`cs`), an initial app route (`to`), and a
|
|
3
|
+
// short-lived token carried only in the URL hash. The Core currently consumes
|
|
4
|
+
// `cs` + `to` through ConnectionProvider.bootParams(); the token is parsed for
|
|
5
|
+
// the public contract but is deliberately not persisted by this module.
|
|
6
|
+
|
|
7
|
+
/** Parsed Open Contract from the app URL. */
|
|
8
|
+
export interface OpenContract {
|
|
9
|
+
/** Browser-reachable reddb HTTP endpoint, e.g. `http://host:5055`. */
|
|
10
|
+
cs?: string;
|
|
11
|
+
/** Initial app route, e.g. `/cluster` or `/c/users/p/table`. */
|
|
12
|
+
to?: string;
|
|
13
|
+
/** Short-lived bearer token, read only from the URL hash. */
|
|
14
|
+
token?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Non-secret connection config a Surface may seed via the app URL. */
|
|
18
|
+
export interface BootParams {
|
|
19
|
+
/** reddb endpoint to connect to (e.g. `http://host:5055` or `red://host`). */
|
|
20
|
+
endpoint?: string;
|
|
21
|
+
/** Legacy initial top-level view: `query` | `collections` | `cluster` | `security`. */
|
|
22
|
+
view?: string;
|
|
23
|
+
/** Open Contract initial route, e.g. `/cluster` or `/c/users/p/table`. */
|
|
24
|
+
to?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Query keys that carry the endpoint (mirrors the legacy ?connection= reading).
|
|
28
|
+
const ENDPOINT_KEYS = ["endpoint", "connection", "red_url", "red"];
|
|
29
|
+
const VIEW_KEY = "view";
|
|
30
|
+
const CS_KEY = "cs";
|
|
31
|
+
const TO_KEY = "to";
|
|
32
|
+
const TOKEN_KEY = "token";
|
|
33
|
+
|
|
34
|
+
// Legacy query secrets are dropped. The Open Contract token is read from the
|
|
35
|
+
// hash only, never from the query string.
|
|
36
|
+
const SECRET_KEY =
|
|
37
|
+
/(token|secret|password|passwd|auth|credential|api[-_]?key|bearer)/i;
|
|
38
|
+
|
|
39
|
+
export type OpenContractErrorCode =
|
|
40
|
+
| "EMPTY_CS"
|
|
41
|
+
| "FILESYSTEM_PATH"
|
|
42
|
+
| "MALFORMED_URL"
|
|
43
|
+
| "UNSUPPORTED_SCHEME"
|
|
44
|
+
| "SECRET_IN_CS"
|
|
45
|
+
| "MALFORMED_ROUTE";
|
|
46
|
+
|
|
47
|
+
export class OpenContractError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
public readonly code: OpenContractErrorCode,
|
|
50
|
+
message: string
|
|
51
|
+
) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "OpenContractError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function splitUrlParts(
|
|
58
|
+
input: string | URL,
|
|
59
|
+
explicitHash = ""
|
|
60
|
+
): { search: string; hash: string } {
|
|
61
|
+
if (input instanceof URL) return { search: input.search, hash: input.hash };
|
|
62
|
+
|
|
63
|
+
const raw = input ?? "";
|
|
64
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
|
65
|
+
const url = new URL(raw);
|
|
66
|
+
return { search: url.search, hash: url.hash };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const hashAt = raw.indexOf("#");
|
|
70
|
+
const search = hashAt >= 0 ? raw.slice(0, hashAt) : raw;
|
|
71
|
+
const hash = hashAt >= 0 ? raw.slice(hashAt) : explicitHash;
|
|
72
|
+
return { search, hash };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function looksLikeFilesystemPath(value: string): boolean {
|
|
76
|
+
return (
|
|
77
|
+
/^file:/i.test(value) ||
|
|
78
|
+
/^~(?:\/|\\|$)/.test(value) ||
|
|
79
|
+
/^(?:\.{1,2})(?:\/|\\|$)/.test(value) ||
|
|
80
|
+
/^(?:\/|\\)/.test(value) ||
|
|
81
|
+
/^[a-z]:[\\/]/i.test(value) ||
|
|
82
|
+
value.includes("\\")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeCs(input: string): string {
|
|
87
|
+
const cs = input.trim().replace(/\/$/, "");
|
|
88
|
+
if (!cs) throw new OpenContractError("EMPTY_CS", "Open Contract cs is empty");
|
|
89
|
+
if (looksLikeFilesystemPath(cs)) {
|
|
90
|
+
throw new OpenContractError(
|
|
91
|
+
"FILESYSTEM_PATH",
|
|
92
|
+
"Open Contract cs must be a browser-reachable URL, not a filesystem path"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let url: URL;
|
|
97
|
+
try {
|
|
98
|
+
url = new URL(cs);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new OpenContractError(
|
|
101
|
+
"MALFORMED_URL",
|
|
102
|
+
"Open Contract cs is not a valid URL"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
107
|
+
throw new OpenContractError(
|
|
108
|
+
"UNSUPPORTED_SCHEME",
|
|
109
|
+
"Open Contract cs must use http:// or https://"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const key of url.searchParams.keys()) {
|
|
114
|
+
if (SECRET_KEY.test(key)) {
|
|
115
|
+
throw new OpenContractError(
|
|
116
|
+
"SECRET_IN_CS",
|
|
117
|
+
"Open Contract secrets must be carried in the app URL hash, not inside cs"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return cs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeRoute(input: string): string {
|
|
126
|
+
const to = input.trim();
|
|
127
|
+
if (!to)
|
|
128
|
+
throw new OpenContractError("MALFORMED_ROUTE", "Open Contract to is empty");
|
|
129
|
+
if (
|
|
130
|
+
/^[a-z][a-z0-9+.-]*:/i.test(to) ||
|
|
131
|
+
to.startsWith("//") ||
|
|
132
|
+
to.includes("\\")
|
|
133
|
+
) {
|
|
134
|
+
throw new OpenContractError(
|
|
135
|
+
"MALFORMED_ROUTE",
|
|
136
|
+
"Open Contract to must be an app route"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const route = to.startsWith("/") ? to : `/${to}`;
|
|
140
|
+
if (
|
|
141
|
+
route === "/query" ||
|
|
142
|
+
route === "/collections" ||
|
|
143
|
+
route === "/cluster" ||
|
|
144
|
+
route === "/security" ||
|
|
145
|
+
route.startsWith("/c/")
|
|
146
|
+
) {
|
|
147
|
+
return route;
|
|
148
|
+
}
|
|
149
|
+
throw new OpenContractError(
|
|
150
|
+
"MALFORMED_ROUTE",
|
|
151
|
+
"Open Contract to must target a known red-ui route"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Parse the public Open Contract (`cs`, `to`, `token`) from a URL/search/hash. */
|
|
156
|
+
export function parseOpenContract(
|
|
157
|
+
input: string | URL,
|
|
158
|
+
explicitHash = ""
|
|
159
|
+
): OpenContract {
|
|
160
|
+
const { search, hash } = splitUrlParts(input, explicitHash);
|
|
161
|
+
const query = new URLSearchParams(search);
|
|
162
|
+
const hashParams = new URLSearchParams(
|
|
163
|
+
hash.startsWith("#") ? hash.slice(1) : hash
|
|
164
|
+
);
|
|
165
|
+
const out: OpenContract = {};
|
|
166
|
+
|
|
167
|
+
const cs = query.get(CS_KEY);
|
|
168
|
+
if (cs !== null) out.cs = normalizeCs(cs);
|
|
169
|
+
|
|
170
|
+
const to = query.get(TO_KEY);
|
|
171
|
+
if (to !== null) out.to = normalizeRoute(to);
|
|
172
|
+
|
|
173
|
+
const token = hashParams.get(TOKEN_KEY);
|
|
174
|
+
if (token) out.token = token;
|
|
175
|
+
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Encode the public Open Contract, carrying `token` only in the hash. */
|
|
180
|
+
export function encodeOpenContract(contract: OpenContract): string {
|
|
181
|
+
const query = new URLSearchParams();
|
|
182
|
+
const hash = new URLSearchParams();
|
|
183
|
+
|
|
184
|
+
if (contract.cs) query.set(CS_KEY, normalizeCs(contract.cs));
|
|
185
|
+
if (contract.to) query.set(TO_KEY, normalizeRoute(contract.to));
|
|
186
|
+
if (contract.token) hash.set(TOKEN_KEY, contract.token);
|
|
187
|
+
|
|
188
|
+
const q = query.toString();
|
|
189
|
+
const h = hash.toString();
|
|
190
|
+
return `${q ? `?${q}` : ""}${h ? `#${h}` : ""}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse boot params from a URL query string (e.g. `location.search`). Returns
|
|
195
|
+
* only the non-secret endpoint + route/view. Query tokens are ignored; hash
|
|
196
|
+
* tokens remain part of the public Open Contract parser and are not surfaced to
|
|
197
|
+
* the provider boot params, so they cannot be persisted by the connection flow.
|
|
198
|
+
*/
|
|
199
|
+
export function parseBootParams(search: string, hash = ""): BootParams {
|
|
200
|
+
const params = new URLSearchParams(search ?? "");
|
|
201
|
+
const out: BootParams = {};
|
|
202
|
+
|
|
203
|
+
const cs = params.get(CS_KEY);
|
|
204
|
+
if (cs !== null) {
|
|
205
|
+
try {
|
|
206
|
+
out.endpoint = normalizeCs(cs);
|
|
207
|
+
} catch {
|
|
208
|
+
// Invalid Open Contract endpoints are rejected by omitting the endpoint;
|
|
209
|
+
// the full parser still throws for callers that need diagnostics.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const to = params.get(TO_KEY);
|
|
214
|
+
if (to !== null) {
|
|
215
|
+
try {
|
|
216
|
+
out.to = normalizeRoute(to);
|
|
217
|
+
} catch {
|
|
218
|
+
/* invalid route: ignore for boot */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const key of ENDPOINT_KEYS) {
|
|
223
|
+
if (out.endpoint) break;
|
|
224
|
+
const v = params.get(key);
|
|
225
|
+
if (v && !SECRET_KEY.test(key)) {
|
|
226
|
+
out.endpoint = v;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const view = params.get(VIEW_KEY);
|
|
232
|
+
if (view) out.view = view;
|
|
233
|
+
|
|
234
|
+
// Read the token from hash to validate the contract source, but do not copy
|
|
235
|
+
// it into BootParams. This prevents accidental persistence via connection
|
|
236
|
+
// history while keeping parseOpenContract() available for the later bootstrap.
|
|
237
|
+
try {
|
|
238
|
+
void parseOpenContract(search, hash).token;
|
|
239
|
+
} catch {
|
|
240
|
+
/* contract diagnostics are for parseOpenContract callers */
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** True iff the boot params carry an endpoint to auto-connect to. */
|
|
247
|
+
export function hasBootEndpoint(b: BootParams): boolean {
|
|
248
|
+
return typeof b.endpoint === "string" && b.endpoint.trim().length > 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** True iff the boot params carry an initial app route. */
|
|
252
|
+
export function hasBootRoute(b: BootParams): boolean {
|
|
253
|
+
return typeof b.to === "string" && b.to.trim().length > 0;
|
|
254
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { ChangeEvent, RedClient } from './client'
|
|
2
|
+
|
|
3
|
+
export interface EventSourceLike {
|
|
4
|
+
close(): void
|
|
5
|
+
onmessage: ((ev: { data: string }) => void) | null
|
|
6
|
+
onerror: ((ev: unknown) => void) | null
|
|
7
|
+
onopen: ((ev: unknown) => void) | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SseFactory = (url: string) => EventSourceLike
|
|
11
|
+
|
|
12
|
+
export interface CDCStreamClientOptions {
|
|
13
|
+
/** Used for REST polling fallback (and SSE URL construction when baseUrl absent). */
|
|
14
|
+
client: Pick<RedClient, 'changes'> & { baseUrl?: string }
|
|
15
|
+
/** If null, SSE is disabled and REST polling is always used. */
|
|
16
|
+
sseFactory?: SseFactory | null
|
|
17
|
+
/**
|
|
18
|
+
* Called once before the first SSE attempt. If it resolves to false (or throws),
|
|
19
|
+
* SSE is considered unsupported and REST polling is used for this subscription.
|
|
20
|
+
* Default: returns true if sseFactory is provided.
|
|
21
|
+
*/
|
|
22
|
+
sseProbe?: () => Promise<boolean>
|
|
23
|
+
/** Reconnect backoff schedule, in ms. Default: [1000, 2000, 4000, 8000, 30000]. */
|
|
24
|
+
backoffMs?: number[]
|
|
25
|
+
/** REST polling interval. Default: 1000ms. */
|
|
26
|
+
pollIntervalMs?: number
|
|
27
|
+
/** Test seam — defaults to globalThis.setTimeout / clearTimeout. */
|
|
28
|
+
timers?: {
|
|
29
|
+
setTimeout: (fn: () => void, ms: number) => unknown
|
|
30
|
+
clearTimeout: (h: unknown) => void
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SubscribeOpts {
|
|
35
|
+
collection?: string
|
|
36
|
+
sinceLsn?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Subscription {
|
|
40
|
+
events: ReadableStream<ChangeEvent>
|
|
41
|
+
close(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_BACKOFF = [1000, 2000, 4000, 8000, 30000]
|
|
45
|
+
|
|
46
|
+
export class CDCStreamClient {
|
|
47
|
+
constructor(public readonly opts: CDCStreamClientOptions) {}
|
|
48
|
+
|
|
49
|
+
subscribe(opts: SubscribeOpts = {}): Subscription {
|
|
50
|
+
return new CDCSubscription(this, opts)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build the SSE URL. Visible for tests. */
|
|
54
|
+
buildSseUrl(collection: string | undefined, sinceLsn: number): string {
|
|
55
|
+
const base = this.opts.client.baseUrl ?? ''
|
|
56
|
+
const params = new URLSearchParams()
|
|
57
|
+
if (sinceLsn > 0) params.set('since', String(sinceLsn))
|
|
58
|
+
if (collection) params.set('collection', collection)
|
|
59
|
+
const qs = params.toString()
|
|
60
|
+
return `${base}/changes/stream${qs ? `?${qs}` : ''}`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class CDCSubscription implements Subscription {
|
|
65
|
+
events: ReadableStream<ChangeEvent>
|
|
66
|
+
private controller!: ReadableStreamDefaultController<ChangeEvent>
|
|
67
|
+
private closed = false
|
|
68
|
+
private lastSeenLsn: number
|
|
69
|
+
private currentSse: EventSourceLike | null = null
|
|
70
|
+
private pollTimer: unknown = null
|
|
71
|
+
private reconnectTimer: unknown = null
|
|
72
|
+
private backoffIdx = 0
|
|
73
|
+
private transport: 'sse' | 'rest' | null = null
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private parent: CDCStreamClient,
|
|
77
|
+
private opts: SubscribeOpts,
|
|
78
|
+
) {
|
|
79
|
+
this.lastSeenLsn = opts.sinceLsn ?? 0
|
|
80
|
+
this.events = new ReadableStream<ChangeEvent>({
|
|
81
|
+
start: (controller) => {
|
|
82
|
+
this.controller = controller
|
|
83
|
+
},
|
|
84
|
+
cancel: () => this.close(),
|
|
85
|
+
})
|
|
86
|
+
void this.connect()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private timers() {
|
|
90
|
+
return (
|
|
91
|
+
this.parent.opts.timers ?? {
|
|
92
|
+
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
93
|
+
clearTimeout: (h) => globalThis.clearTimeout(h as ReturnType<typeof globalThis.setTimeout>),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async connect() {
|
|
99
|
+
if (this.closed) return
|
|
100
|
+
if (this.transport === null) {
|
|
101
|
+
// First connect — choose transport.
|
|
102
|
+
const factory = this.parent.opts.sseFactory
|
|
103
|
+
if (factory === null || factory === undefined) {
|
|
104
|
+
this.transport = 'rest'
|
|
105
|
+
} else {
|
|
106
|
+
const probe = this.parent.opts.sseProbe ?? (async () => true)
|
|
107
|
+
let supported = false
|
|
108
|
+
try {
|
|
109
|
+
supported = await probe()
|
|
110
|
+
} catch {
|
|
111
|
+
supported = false
|
|
112
|
+
}
|
|
113
|
+
if (this.closed) return
|
|
114
|
+
this.transport = supported ? 'sse' : 'rest'
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.transport === 'sse') this.startSse()
|
|
119
|
+
else this.startRest()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private startSse() {
|
|
123
|
+
const factory = this.parent.opts.sseFactory
|
|
124
|
+
if (!factory) {
|
|
125
|
+
this.transport = 'rest'
|
|
126
|
+
this.startRest()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const url = this.parent.buildSseUrl(this.opts.collection, this.lastSeenLsn)
|
|
130
|
+
let es: EventSourceLike
|
|
131
|
+
try {
|
|
132
|
+
es = factory(url)
|
|
133
|
+
} catch {
|
|
134
|
+
this.scheduleReconnect()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
this.currentSse = es
|
|
138
|
+
es.onopen = () => {
|
|
139
|
+
this.backoffIdx = 0
|
|
140
|
+
}
|
|
141
|
+
es.onmessage = (ev) => {
|
|
142
|
+
try {
|
|
143
|
+
const data = JSON.parse(ev.data) as ChangeEvent
|
|
144
|
+
this.emit(data)
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore malformed payloads
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
es.onerror = () => {
|
|
150
|
+
if (this.closed) return
|
|
151
|
+
try {
|
|
152
|
+
es.close()
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
156
|
+
if (this.currentSse === es) this.currentSse = null
|
|
157
|
+
this.scheduleReconnect()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private startRest() {
|
|
162
|
+
const tick = async () => {
|
|
163
|
+
if (this.closed) return
|
|
164
|
+
try {
|
|
165
|
+
const events = this.opts.collection
|
|
166
|
+
? await this.parent.opts.client.changes(this.lastSeenLsn, { collection: this.opts.collection })
|
|
167
|
+
: await this.parent.opts.client.changes(this.lastSeenLsn)
|
|
168
|
+
if (this.closed) return
|
|
169
|
+
for (const e of events) this.emit(e)
|
|
170
|
+
this.backoffIdx = 0
|
|
171
|
+
const interval = this.parent.opts.pollIntervalMs ?? 1000
|
|
172
|
+
this.pollTimer = this.timers().setTimeout(() => {
|
|
173
|
+
this.pollTimer = null
|
|
174
|
+
void tick()
|
|
175
|
+
}, interval)
|
|
176
|
+
} catch {
|
|
177
|
+
if (this.closed) return
|
|
178
|
+
this.scheduleReconnect()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
void tick()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private emit(e: ChangeEvent) {
|
|
185
|
+
if (this.closed) return
|
|
186
|
+
if (e.lsn <= this.lastSeenLsn) return
|
|
187
|
+
this.lastSeenLsn = e.lsn
|
|
188
|
+
try {
|
|
189
|
+
this.controller.enqueue(e)
|
|
190
|
+
} catch {
|
|
191
|
+
// stream may have been cancelled — close ourselves.
|
|
192
|
+
this.close()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private scheduleReconnect() {
|
|
197
|
+
if (this.closed) return
|
|
198
|
+
const delays = this.parent.opts.backoffMs ?? DEFAULT_BACKOFF
|
|
199
|
+
const delay = delays[Math.min(this.backoffIdx, delays.length - 1)]
|
|
200
|
+
this.backoffIdx++
|
|
201
|
+
this.reconnectTimer = this.timers().setTimeout(() => {
|
|
202
|
+
this.reconnectTimer = null
|
|
203
|
+
if (this.transport === 'sse') this.startSse()
|
|
204
|
+
else this.startRest()
|
|
205
|
+
}, delay)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
close() {
|
|
209
|
+
if (this.closed) return
|
|
210
|
+
this.closed = true
|
|
211
|
+
if (this.currentSse) {
|
|
212
|
+
try {
|
|
213
|
+
this.currentSse.close()
|
|
214
|
+
} catch {
|
|
215
|
+
// ignore
|
|
216
|
+
}
|
|
217
|
+
this.currentSse = null
|
|
218
|
+
}
|
|
219
|
+
const { clearTimeout } = this.timers()
|
|
220
|
+
if (this.pollTimer) {
|
|
221
|
+
clearTimeout(this.pollTimer)
|
|
222
|
+
this.pollTimer = null
|
|
223
|
+
}
|
|
224
|
+
if (this.reconnectTimer) {
|
|
225
|
+
clearTimeout(this.reconnectTimer)
|
|
226
|
+
this.reconnectTimer = null
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
this.controller.close()
|
|
230
|
+
} catch {
|
|
231
|
+
// already closed
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|