@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.
Files changed (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. 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
+ }