@nbt-dev/components 0.0.5
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 +177 -0
- package/README.md +10 -0
- package/TRADEMARKS.md +49 -0
- package/dist/chunk-3ZM6YOA4.js +704 -0
- package/dist/chunk-3ZM6YOA4.js.map +7 -0
- package/dist/chunk-7B2T5ZNG.js +467 -0
- package/dist/chunk-7B2T5ZNG.js.map +7 -0
- package/dist/chunk-S7VBQE6Y.js +636 -0
- package/dist/chunk-S7VBQE6Y.js.map +7 -0
- package/dist/chunk-UPEOXMLZ.js +625 -0
- package/dist/chunk-UPEOXMLZ.js.map +7 -0
- package/dist/core/auth.d.ts +13 -0
- package/dist/core/bulk-decoder.d.ts +13 -0
- package/dist/core/config.d.ts +10 -0
- package/dist/core/data-store.d.ts +20 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/use-bulk-stream.d.ts +24 -0
- package/dist/core/use-cartridge-info.d.ts +14 -0
- package/dist/core/utils.d.ts +2 -0
- package/dist/editor/index.d.ts +7 -0
- package/dist/editor/index.js +16 -0
- package/dist/editor/index.js.map +7 -0
- package/dist/editor/lsp-client.d.ts +57 -0
- package/dist/editor/lsp-extensions.d.ts +4 -0
- package/dist/editor/nbt-editor.d.ts +13 -0
- package/dist/editor/nbt-language.d.ts +7 -0
- package/dist/generated/bulk-protocol.d.ts +36 -0
- package/dist/graph/diagram.d.ts +5 -0
- package/dist/graph/entity-graph-utils.d.ts +92 -0
- package/dist/graph/entity-node.d.ts +9 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.js +19 -0
- package/dist/graph/index.js.map +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +134 -0
- package/dist/index.js.map +7 -0
- package/dist/styles.css +2 -0
- package/dist/table/data-table.d.ts +9 -0
- package/dist/table/index.d.ts +3 -0
- package/dist/table/index.js +11 -0
- package/dist/table/index.js.map +7 -0
- package/dist/table/value-popover.d.ts +18 -0
- package/package.json +77 -0
- package/src/core/auth.ts +100 -0
- package/src/core/bulk-decoder.ts +178 -0
- package/src/core/config.tsx +39 -0
- package/src/core/data-store.ts +113 -0
- package/src/core/index.ts +34 -0
- package/src/core/use-bulk-stream.ts +412 -0
- package/src/core/use-cartridge-info.ts +100 -0
- package/src/core/utils.ts +6 -0
- package/src/editor/index.ts +13 -0
- package/src/editor/lsp-client.ts +227 -0
- package/src/editor/lsp-extensions.ts +191 -0
- package/src/editor/nbt-editor.tsx +142 -0
- package/src/editor/nbt-language.ts +151 -0
- package/src/generated/bulk-protocol.ts +63 -0
- package/src/graph/diagram.tsx +296 -0
- package/src/graph/entity-graph-utils.ts +423 -0
- package/src/graph/entity-node.tsx +122 -0
- package/src/graph/index.ts +19 -0
- package/src/index.ts +7 -0
- package/src/styles.css +94 -0
- package/src/table/data-table.tsx +274 -0
- package/src/table/index.ts +5 -0
- package/src/table/value-popover.tsx +230 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// Runtime config for the devtools, replacing the inspector's compile-time
|
|
4
|
+
// `NIMBIT_API_ENDPOINT` define. Hosts pass the console's base URL once via
|
|
5
|
+
// <NimbitDevTools apiBaseUrl="…" /> and every fetch/WebSocket derives from it.
|
|
6
|
+
// Empty string => same-origin (the inspector default when served by the console).
|
|
7
|
+
export type DevToolsConfig = {
|
|
8
|
+
apiBaseUrl: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DevToolsConfigContext = React.createContext<DevToolsConfig>({
|
|
12
|
+
apiBaseUrl: "",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const DevToolsConfigProvider: React.FC<{
|
|
16
|
+
apiBaseUrl?: string;
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}> = ({ apiBaseUrl = "", children }) => {
|
|
19
|
+
const value = React.useMemo(() => ({ apiBaseUrl }), [apiBaseUrl]);
|
|
20
|
+
return (
|
|
21
|
+
<DevToolsConfigContext.Provider value={value}>
|
|
22
|
+
{children}
|
|
23
|
+
</DevToolsConfigContext.Provider>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function useDevToolsConfig(): DevToolsConfig {
|
|
28
|
+
return React.useContext(DevToolsConfigContext);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Turn an http(s) base into a ws(s) base. Empty => derive from window.location.
|
|
32
|
+
export function wsBaseFrom(apiBaseUrl: string): string {
|
|
33
|
+
if (!apiBaseUrl) {
|
|
34
|
+
const loc = window.location;
|
|
35
|
+
const proto = loc.protocol === "https:" ? "wss:" : "ws:";
|
|
36
|
+
return `${proto}//${loc.host}`;
|
|
37
|
+
}
|
|
38
|
+
return apiBaseUrl.replace(/^http(s?):/, "ws$1:");
|
|
39
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FRAME_DELTA_INS,
|
|
3
|
+
FRAME_DELTA_UPD,
|
|
4
|
+
FRAME_DELTA_DEL,
|
|
5
|
+
type ColumnDef,
|
|
6
|
+
type SchemaFrame,
|
|
7
|
+
type DeltaFrame,
|
|
8
|
+
} from "../generated/bulk-protocol";
|
|
9
|
+
|
|
10
|
+
export class BulkDataStore {
|
|
11
|
+
columns: ColumnDef[] = [];
|
|
12
|
+
rows: string[][] = [];
|
|
13
|
+
totalRows = 0;
|
|
14
|
+
searchActive = false;
|
|
15
|
+
|
|
16
|
+
private _fullRows: string[][] = [];
|
|
17
|
+
private _fullTotalRows = 0;
|
|
18
|
+
private _idColIndex = -1;
|
|
19
|
+
private _query = "";
|
|
20
|
+
|
|
21
|
+
applySchema(schema: SchemaFrame): void {
|
|
22
|
+
this.columns = schema.columns;
|
|
23
|
+
this.totalRows = schema.totalRows;
|
|
24
|
+
this.rows = [];
|
|
25
|
+
this._fullRows = [];
|
|
26
|
+
this._fullTotalRows = schema.totalRows;
|
|
27
|
+
this.searchActive = false;
|
|
28
|
+
this._query = "";
|
|
29
|
+
this._idColIndex = schema.columns.findIndex((c) => c.name === "id");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
appendChunk(chunk: string[][]): void {
|
|
33
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
34
|
+
const row = chunk[i]!;
|
|
35
|
+
this._fullRows.push(row);
|
|
36
|
+
if (!this.searchActive) this.rows.push(row);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
applyDelta(delta: DeltaFrame): void {
|
|
41
|
+
const target = this._fullRows;
|
|
42
|
+
if (delta.op === FRAME_DELTA_INS && delta.rowData) {
|
|
43
|
+
target.push(delta.rowData);
|
|
44
|
+
this._fullTotalRows++;
|
|
45
|
+
this.totalRows++;
|
|
46
|
+
if (!this.searchActive) this.rows = this._fullRows;
|
|
47
|
+
else this._applySearch();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (delta.op === FRAME_DELTA_UPD && delta.rowData) {
|
|
51
|
+
const idx = this._findRowById(target, delta.rowData);
|
|
52
|
+
if (idx >= 0) target[idx] = delta.rowData;
|
|
53
|
+
if (this.searchActive) this._applySearch();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (delta.op === FRAME_DELTA_DEL && delta.id !== undefined) {
|
|
57
|
+
const idStr = String(delta.id);
|
|
58
|
+
const idx = this._findRowByIdStr(target, idStr);
|
|
59
|
+
if (idx >= 0) {
|
|
60
|
+
target.splice(idx, 1);
|
|
61
|
+
this._fullTotalRows--;
|
|
62
|
+
this.totalRows--;
|
|
63
|
+
if (!this.searchActive) this.rows = this._fullRows;
|
|
64
|
+
else this._applySearch();
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
search(query: string): void {
|
|
71
|
+
const q = query.trim().toLowerCase();
|
|
72
|
+
if (!q) { this.exitSearch(); return; }
|
|
73
|
+
this.searchActive = true;
|
|
74
|
+
this._query = q;
|
|
75
|
+
this._applySearch();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
exitSearch(): void {
|
|
79
|
+
if (!this.searchActive) return;
|
|
80
|
+
this.rows = this._fullRows;
|
|
81
|
+
this.totalRows = this._fullTotalRows;
|
|
82
|
+
this.searchActive = false;
|
|
83
|
+
this._query = "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getRowCount(): number {
|
|
87
|
+
return this.rows.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private _findRowById(rows: string[][], rowData: string[]): number {
|
|
91
|
+
if (this._idColIndex < 0) return -1;
|
|
92
|
+
const id = rowData[this._idColIndex];
|
|
93
|
+
for (let i = 0; i < rows.length; i++) {
|
|
94
|
+
if (rows[i]![this._idColIndex] === id) return i;
|
|
95
|
+
}
|
|
96
|
+
return -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private _findRowByIdStr(rows: string[][], idStr: string): number {
|
|
100
|
+
if (this._idColIndex < 0) return -1;
|
|
101
|
+
for (let i = 0; i < rows.length; i++) {
|
|
102
|
+
if (rows[i]![this._idColIndex] === idStr) return i;
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private _applySearch(): void {
|
|
108
|
+
this.rows = this._fullRows.filter((row) =>
|
|
109
|
+
row.some((value) => value.toLowerCase().includes(this._query)),
|
|
110
|
+
);
|
|
111
|
+
this.totalRows = this.rows.length;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared runtime every primitive leans on: the console-base-URL config context,
|
|
2
|
+
// the one bulk-WS provider that table + graph demux off, the live cartridge
|
|
3
|
+
// registry, and the tailwind `cn` helper. Standalone apps compose:
|
|
4
|
+
// <DevToolsConfigProvider apiBaseUrl={...}><BulkStreamProvider registry={...}>
|
|
5
|
+
export {
|
|
6
|
+
DevToolsConfigProvider,
|
|
7
|
+
useDevToolsConfig,
|
|
8
|
+
wsBaseFrom,
|
|
9
|
+
} from "./config";
|
|
10
|
+
export type { DevToolsConfig } from "./config";
|
|
11
|
+
export { cn } from "./utils";
|
|
12
|
+
export {
|
|
13
|
+
BulkStreamProvider,
|
|
14
|
+
useBulkSubscription,
|
|
15
|
+
useBulkRowCounts,
|
|
16
|
+
} from "./use-bulk-stream";
|
|
17
|
+
export type { BulkSubscription } from "./use-bulk-stream";
|
|
18
|
+
export { useLiveBulkRegistry } from "./use-cartridge-info";
|
|
19
|
+
export type {
|
|
20
|
+
BulkEntity,
|
|
21
|
+
BulkRegistry,
|
|
22
|
+
LiveRegistryState,
|
|
23
|
+
} from "./use-cartridge-info";
|
|
24
|
+
export {
|
|
25
|
+
getDevToolsToken,
|
|
26
|
+
setDevToolsToken,
|
|
27
|
+
clearDevToolsToken,
|
|
28
|
+
authHeaders,
|
|
29
|
+
fetchWhoAmI,
|
|
30
|
+
devToolsSignIn,
|
|
31
|
+
devToolsSignOut,
|
|
32
|
+
wsAuthProtocols,
|
|
33
|
+
} from "./auth";
|
|
34
|
+
export type { WhoAmI } from "./auth";
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { BulkDataStore } from "./data-store";
|
|
9
|
+
import {
|
|
10
|
+
getFrameType,
|
|
11
|
+
getFrameSid,
|
|
12
|
+
parseSchema,
|
|
13
|
+
parseDataChunk,
|
|
14
|
+
parseDelta,
|
|
15
|
+
parseError,
|
|
16
|
+
encodeStreamCmd,
|
|
17
|
+
} from "./bulk-decoder";
|
|
18
|
+
import {
|
|
19
|
+
FRAME_SCHEMA,
|
|
20
|
+
FRAME_DATA,
|
|
21
|
+
FRAME_DATA_END,
|
|
22
|
+
FRAME_DELTA_INS,
|
|
23
|
+
FRAME_DELTA_UPD,
|
|
24
|
+
FRAME_DELTA_DEL,
|
|
25
|
+
FRAME_ERROR,
|
|
26
|
+
type ColumnDef,
|
|
27
|
+
} from "../generated/bulk-protocol";
|
|
28
|
+
import type { BulkRegistry } from "./use-cartridge-info";
|
|
29
|
+
import { useDevToolsConfig, wsBaseFrom } from "./config";
|
|
30
|
+
import { getDevToolsToken } from "./auth";
|
|
31
|
+
|
|
32
|
+
// One per-entity table view, demultiplexed off the shared socket by sid. The
|
|
33
|
+
// object is stable across renders and mutated in place; consumers re-render via
|
|
34
|
+
// the listener set (and the cheap per-chunk onRender for virtualization).
|
|
35
|
+
type View = {
|
|
36
|
+
sid: number;
|
|
37
|
+
cart: string;
|
|
38
|
+
entity: string;
|
|
39
|
+
store: BulkDataStore;
|
|
40
|
+
columns: ColumnDef[];
|
|
41
|
+
totalRows: number;
|
|
42
|
+
loadedRows: number;
|
|
43
|
+
streaming: boolean;
|
|
44
|
+
error: string | null;
|
|
45
|
+
streamRequested: boolean; // sent a `sub` (full stream) at least once
|
|
46
|
+
onRender: (() => void) | null;
|
|
47
|
+
listeners: Set<() => void>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type BulkSubscription = {
|
|
51
|
+
store: BulkDataStore;
|
|
52
|
+
connected: boolean;
|
|
53
|
+
streaming: boolean;
|
|
54
|
+
error: string | null;
|
|
55
|
+
columns: ColumnDef[];
|
|
56
|
+
totalRows: number;
|
|
57
|
+
loadedRows: number;
|
|
58
|
+
search: (q: string) => void;
|
|
59
|
+
clearSearch: () => void;
|
|
60
|
+
setOnRender: (fn: (() => void) | null) => void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type Ctx = {
|
|
64
|
+
getView: (cart: string, entity: string) => View;
|
|
65
|
+
ensureStreamed: (view: View) => void;
|
|
66
|
+
search: (view: View, query: string) => void;
|
|
67
|
+
clearSearch: (view: View) => void;
|
|
68
|
+
subscribe: (view: View, cb: () => void) => () => void;
|
|
69
|
+
connected: boolean;
|
|
70
|
+
error: string | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const BulkStreamContext = createContext<Ctx | null>(null);
|
|
74
|
+
|
|
75
|
+
function base64UrlEncode(value: string): string {
|
|
76
|
+
if (typeof btoa === "function") {
|
|
77
|
+
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
78
|
+
}
|
|
79
|
+
throw new Error("No base64 encoder available for bulk WS auth");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function bulkAuthProtocols(token: string): string[] {
|
|
83
|
+
return ["nimbit-bulk", `auth-${base64UrlEncode(token)}`];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let _wsTokenCache: { apiBaseUrl: string; token: string; at: number } | null = null;
|
|
87
|
+
const WS_TOKEN_TTL_MS = 60_000;
|
|
88
|
+
|
|
89
|
+
// Resolve the session token to forward in the WS subprotocol. Prefer the
|
|
90
|
+
// devtools-managed token (set by its own sign-in); otherwise fall back to the
|
|
91
|
+
// host page's cookie session via auth.Session.current. Returns null when there
|
|
92
|
+
// is neither — on an unconfigured/dev console the WS upgrade is open anyway, so
|
|
93
|
+
// we still connect without an auth subprotocol.
|
|
94
|
+
async function fetchWsToken(
|
|
95
|
+
signal: AbortSignal,
|
|
96
|
+
apiBaseUrl: string,
|
|
97
|
+
): Promise<string | null> {
|
|
98
|
+
const own = getDevToolsToken();
|
|
99
|
+
if (own) return own;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (_wsTokenCache?.apiBaseUrl === apiBaseUrl && now - _wsTokenCache.at < WS_TOKEN_TTL_MS) {
|
|
102
|
+
return _wsTokenCache.token;
|
|
103
|
+
}
|
|
104
|
+
const r = await fetch(`${apiBaseUrl}/api/auth/session/current`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
signal,
|
|
107
|
+
credentials: "include",
|
|
108
|
+
headers: { "content-type": "application/json" },
|
|
109
|
+
});
|
|
110
|
+
if (!r.ok) return null;
|
|
111
|
+
const j = (await r.json()) as { session?: { token?: string } };
|
|
112
|
+
const token = j.session?.token;
|
|
113
|
+
if (!token) return null;
|
|
114
|
+
_wsTokenCache = { apiBaseUrl, token, at: now };
|
|
115
|
+
return token;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function invalidateWsToken() {
|
|
119
|
+
_wsTokenCache = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function notify(view: View) {
|
|
123
|
+
view.listeners.forEach((cb) => cb());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Props = { registry: BulkRegistry; children: React.ReactNode };
|
|
127
|
+
|
|
128
|
+
export function BulkStreamProvider({ registry, children }: Props): React.ReactElement {
|
|
129
|
+
const { apiBaseUrl } = useDevToolsConfig();
|
|
130
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
131
|
+
const sendQueueRef = useRef<string[]>([]);
|
|
132
|
+
const sidCounterRef = useRef(1);
|
|
133
|
+
const viewsBySidRef = useRef<Map<number, View>>(new Map());
|
|
134
|
+
const viewsByKeyRef = useRef<Map<string, View>>(new Map());
|
|
135
|
+
const preloadedRef = useRef(false);
|
|
136
|
+
|
|
137
|
+
const [connected, setConnected] = useState(false);
|
|
138
|
+
const [error, setError] = useState<string | null>(null);
|
|
139
|
+
|
|
140
|
+
const sendCmd = (cmd: string) => {
|
|
141
|
+
const ws = wsRef.current;
|
|
142
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(cmd);
|
|
143
|
+
else sendQueueRef.current.push(cmd);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const flushQueue = () => {
|
|
147
|
+
const ws = wsRef.current;
|
|
148
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
149
|
+
const q = sendQueueRef.current;
|
|
150
|
+
sendQueueRef.current = [];
|
|
151
|
+
for (const cmd of q) ws.send(cmd);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const getView = (cart: string, entity: string): View => {
|
|
155
|
+
// The server registers entity ops under "<cart>/<entity-lower>" (route
|
|
156
|
+
// path casing), so normalize here — preload and subscription then share one
|
|
157
|
+
// view per entity regardless of the caller's casing.
|
|
158
|
+
const entLower = entity.toLowerCase();
|
|
159
|
+
const key = `${cart}/${entLower}`;
|
|
160
|
+
const existing = viewsByKeyRef.current.get(key);
|
|
161
|
+
if (existing) return existing;
|
|
162
|
+
const sid = sidCounterRef.current++;
|
|
163
|
+
const view: View = {
|
|
164
|
+
sid,
|
|
165
|
+
cart,
|
|
166
|
+
entity: entLower,
|
|
167
|
+
store: new BulkDataStore(),
|
|
168
|
+
columns: [],
|
|
169
|
+
totalRows: 0,
|
|
170
|
+
loadedRows: 0,
|
|
171
|
+
streaming: false,
|
|
172
|
+
error: null,
|
|
173
|
+
streamRequested: false,
|
|
174
|
+
onRender: null,
|
|
175
|
+
listeners: new Set(),
|
|
176
|
+
};
|
|
177
|
+
viewsByKeyRef.current.set(key, view);
|
|
178
|
+
viewsBySidRef.current.set(sid, view);
|
|
179
|
+
return view;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const ensureStreamed = (view: View) => {
|
|
183
|
+
if (view.streamRequested) return;
|
|
184
|
+
view.streamRequested = true;
|
|
185
|
+
sendCmd(encodeStreamCmd(view.sid, view.cart, view.entity));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const handleMessage = (ev: MessageEvent) => {
|
|
189
|
+
const buf = ev.data as ArrayBuffer;
|
|
190
|
+
const sid = getFrameSid(buf);
|
|
191
|
+
const view = viewsBySidRef.current.get(sid);
|
|
192
|
+
if (!view) return;
|
|
193
|
+
const ft = getFrameType(buf);
|
|
194
|
+
const store = view.store;
|
|
195
|
+
switch (ft) {
|
|
196
|
+
case FRAME_SCHEMA: {
|
|
197
|
+
const schema = parseSchema(buf);
|
|
198
|
+
store.applySchema(schema);
|
|
199
|
+
view.columns = schema.columns;
|
|
200
|
+
view.totalRows = schema.totalRows;
|
|
201
|
+
view.loadedRows = 0;
|
|
202
|
+
view.streaming = true;
|
|
203
|
+
notify(view);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case FRAME_DATA:
|
|
207
|
+
{
|
|
208
|
+
const rows = parseDataChunk(buf, store.columns);
|
|
209
|
+
store.appendChunk(rows);
|
|
210
|
+
view.loadedRows = store.getRowCount();
|
|
211
|
+
view.onRender?.();
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case FRAME_DATA_END:
|
|
215
|
+
view.streaming = false;
|
|
216
|
+
notify(view);
|
|
217
|
+
break;
|
|
218
|
+
case FRAME_DELTA_INS:
|
|
219
|
+
case FRAME_DELTA_UPD:
|
|
220
|
+
case FRAME_DELTA_DEL: {
|
|
221
|
+
const delta = parseDelta(buf, store.columns);
|
|
222
|
+
store.applyDelta(delta);
|
|
223
|
+
view.totalRows = store.totalRows;
|
|
224
|
+
view.loadedRows = store.getRowCount();
|
|
225
|
+
view.onRender?.();
|
|
226
|
+
notify(view);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case FRAME_ERROR:
|
|
230
|
+
view.error = parseError(buf);
|
|
231
|
+
view.streaming = false;
|
|
232
|
+
notify(view);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Open one shared socket for the provider's lifetime.
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
const ac = new AbortController();
|
|
240
|
+
let cancelled = false;
|
|
241
|
+
let opened = false;
|
|
242
|
+
let ws: WebSocket | null = null;
|
|
243
|
+
sendQueueRef.current = [];
|
|
244
|
+
sidCounterRef.current = 1;
|
|
245
|
+
viewsBySidRef.current.clear();
|
|
246
|
+
viewsByKeyRef.current.clear();
|
|
247
|
+
preloadedRef.current = false;
|
|
248
|
+
setConnected(false);
|
|
249
|
+
setError(null);
|
|
250
|
+
|
|
251
|
+
(async () => {
|
|
252
|
+
let token: string | null = null;
|
|
253
|
+
try {
|
|
254
|
+
token = await fetchWsToken(ac.signal, apiBaseUrl);
|
|
255
|
+
} catch {
|
|
256
|
+
// Token lookup failed; still attempt the connection — an unconfigured
|
|
257
|
+
// dev console accepts it, a configured one will close it (handled below).
|
|
258
|
+
}
|
|
259
|
+
if (cancelled) return;
|
|
260
|
+
// With a token, authenticate via the subprotocol; without one, connect
|
|
261
|
+
// bare (dev/unconfigured consoles synthesize auth on their side).
|
|
262
|
+
ws = token
|
|
263
|
+
? new WebSocket(`${wsBaseFrom(apiBaseUrl)}/_ws/bulk`, bulkAuthProtocols(token))
|
|
264
|
+
: new WebSocket(`${wsBaseFrom(apiBaseUrl)}/_ws/bulk`, ["nimbit-bulk"]);
|
|
265
|
+
ws.binaryType = "arraybuffer";
|
|
266
|
+
wsRef.current = ws;
|
|
267
|
+
ws.onopen = () => {
|
|
268
|
+
if (cancelled || wsRef.current !== ws) return;
|
|
269
|
+
opened = true;
|
|
270
|
+
setConnected(true);
|
|
271
|
+
setError(null);
|
|
272
|
+
flushQueue();
|
|
273
|
+
};
|
|
274
|
+
ws.onmessage = handleMessage;
|
|
275
|
+
ws.onerror = () => {};
|
|
276
|
+
ws.onclose = () => {
|
|
277
|
+
if (cancelled || wsRef.current !== ws) return;
|
|
278
|
+
setConnected(false);
|
|
279
|
+
wsRef.current = null;
|
|
280
|
+
if (!opened) {
|
|
281
|
+
invalidateWsToken();
|
|
282
|
+
setError("WebSocket connection closed");
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
})();
|
|
286
|
+
|
|
287
|
+
return () => {
|
|
288
|
+
cancelled = true;
|
|
289
|
+
ac.abort();
|
|
290
|
+
if (ws) {
|
|
291
|
+
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
|
|
292
|
+
try {
|
|
293
|
+
ws.close();
|
|
294
|
+
} catch {}
|
|
295
|
+
if (wsRef.current === ws) wsRef.current = null;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
299
|
+
}, [apiBaseUrl]);
|
|
300
|
+
|
|
301
|
+
// The live protocol exposes only sub/unsub. Subscribe every known entity once
|
|
302
|
+
// so schema, row counts, snapshots, and deltas stay available to all tabs.
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (!connected) return;
|
|
305
|
+
if (preloadedRef.current) return;
|
|
306
|
+
const keys = Object.keys(registry);
|
|
307
|
+
if (keys.length === 0) return;
|
|
308
|
+
preloadedRef.current = true;
|
|
309
|
+
for (const cart of keys) {
|
|
310
|
+
for (const ent of registry[cart] ?? []) {
|
|
311
|
+
const view = getView(cart, ent.name);
|
|
312
|
+
ensureStreamed(view);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
316
|
+
}, [connected, registry]);
|
|
317
|
+
|
|
318
|
+
const search = (view: View, query: string) => {
|
|
319
|
+
view.store.search(query);
|
|
320
|
+
view.totalRows = view.store.totalRows;
|
|
321
|
+
view.loadedRows = view.store.getRowCount();
|
|
322
|
+
notify(view);
|
|
323
|
+
view.onRender?.();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const clearSearch = (view: View) => {
|
|
327
|
+
view.store.exitSearch();
|
|
328
|
+
view.totalRows = view.store.totalRows;
|
|
329
|
+
view.loadedRows = view.store.getRowCount();
|
|
330
|
+
notify(view);
|
|
331
|
+
view.onRender?.();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const subscribe = (view: View, cb: () => void) => {
|
|
335
|
+
view.listeners.add(cb);
|
|
336
|
+
return () => {
|
|
337
|
+
view.listeners.delete(cb);
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const ctx: Ctx = {
|
|
342
|
+
getView,
|
|
343
|
+
ensureStreamed,
|
|
344
|
+
search,
|
|
345
|
+
clearSearch,
|
|
346
|
+
subscribe,
|
|
347
|
+
connected,
|
|
348
|
+
error,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return React.createElement(BulkStreamContext.Provider, { value: ctx }, children);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Row counts for every entity in the registry, sourced from the SCHEMA preload
|
|
355
|
+
// the provider already runs on connect (FRAME_SCHEMA carries totalRows). No row
|
|
356
|
+
// streaming is triggered — we subscribe to each view but never ensureStreamed.
|
|
357
|
+
// Keyed by the graph id `cart:Entity` (original casing) for the Diagram tab.
|
|
358
|
+
export function useBulkRowCounts(registry: BulkRegistry): Record<string, number> {
|
|
359
|
+
const ctx = useContext(BulkStreamContext);
|
|
360
|
+
if (!ctx) throw new Error("useBulkRowCounts must be used within BulkStreamProvider");
|
|
361
|
+
const [, force] = useState(0);
|
|
362
|
+
|
|
363
|
+
const pairs: Array<{ id: string; view: View }> = [];
|
|
364
|
+
for (const cart of Object.keys(registry)) {
|
|
365
|
+
for (const ent of registry[cart] ?? []) {
|
|
366
|
+
pairs.push({ id: `${cart}:${ent.name}`, view: ctx.getView(cart, ent.name) });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const key = pairs.map((p) => p.id).join("|");
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
const unsubs = pairs.map((p) => ctx.subscribe(p.view, () => force((n) => n + 1)));
|
|
373
|
+
return () => unsubs.forEach((u) => u());
|
|
374
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
375
|
+
}, [key]);
|
|
376
|
+
|
|
377
|
+
const counts: Record<string, number> = {};
|
|
378
|
+
for (const p of pairs) counts[p.id] = p.view.totalRows;
|
|
379
|
+
return counts;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Keyed to a single entity. The view (schema + rows + deltas) is shared and
|
|
383
|
+
// cached in the provider, so switching tabs is instant once visited.
|
|
384
|
+
export function useBulkSubscription(cart: string, entity: string): BulkSubscription {
|
|
385
|
+
const ctx = useContext(BulkStreamContext);
|
|
386
|
+
if (!ctx) throw new Error("useBulkSubscription must be used within BulkStreamProvider");
|
|
387
|
+
|
|
388
|
+
const view = ctx.getView(cart, entity);
|
|
389
|
+
const [, force] = useState(0);
|
|
390
|
+
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
const unsub = ctx.subscribe(view, () => force((n) => n + 1));
|
|
393
|
+
ctx.ensureStreamed(view);
|
|
394
|
+
return unsub;
|
|
395
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
396
|
+
}, [view]);
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
store: view.store,
|
|
400
|
+
connected: ctx.connected,
|
|
401
|
+
streaming: view.streaming,
|
|
402
|
+
error: view.error ?? ctx.error,
|
|
403
|
+
columns: view.columns,
|
|
404
|
+
totalRows: view.totalRows,
|
|
405
|
+
loadedRows: view.loadedRows,
|
|
406
|
+
search: (q: string) => ctx.search(view, q),
|
|
407
|
+
clearSearch: () => ctx.clearSearch(view),
|
|
408
|
+
setOnRender: (fn) => {
|
|
409
|
+
view.onRender = fn;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useDevToolsConfig } from "./config";
|
|
3
|
+
import { authHeaders } from "./auth";
|
|
4
|
+
|
|
5
|
+
// Live cartridge/entity registry for the Data tab. Instead of the build-time
|
|
6
|
+
// `BULK_REGISTRY` (every cart in the repo, generated by `nbt generate`), we
|
|
7
|
+
// read the daemon's `/_console/contracts` — which only lists *running*
|
|
8
|
+
// cartridges and carries each entity's `searchFields`. Bulk WS routes are
|
|
9
|
+
// derived live (`/_ws/bulk/<cart>/<entity-lower>`) and fed straight into
|
|
10
|
+
// useBulkStream. So the tab reflects what is actually installed right now.
|
|
11
|
+
|
|
12
|
+
export type BulkEntity = {
|
|
13
|
+
name: string;
|
|
14
|
+
route: string;
|
|
15
|
+
searchFields: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
export type BulkRegistry = Record<string, BulkEntity[]>;
|
|
18
|
+
|
|
19
|
+
type Contract = {
|
|
20
|
+
cartridge?: string;
|
|
21
|
+
core?: boolean;
|
|
22
|
+
owns?: Record<string, { searchFields?: string[] }>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function buildRegistry(contracts: Contract[]): { reg: BulkRegistry; core: Set<string> } {
|
|
26
|
+
const reg: BulkRegistry = {};
|
|
27
|
+
const core = new Set<string>();
|
|
28
|
+
for (const c of contracts) {
|
|
29
|
+
const cart = c.cartridge;
|
|
30
|
+
if (!cart || !c.owns) continue;
|
|
31
|
+
const entities: BulkEntity[] = [];
|
|
32
|
+
for (const [name, ent] of Object.entries(c.owns)) {
|
|
33
|
+
const sf = Array.isArray(ent?.searchFields) ? ent.searchFields : [];
|
|
34
|
+
entities.push({
|
|
35
|
+
name,
|
|
36
|
+
route: `/_ws/bulk/${cart}/${name.toLowerCase()}`,
|
|
37
|
+
searchFields: sf,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (entities.length > 0) {
|
|
41
|
+
reg[cart] = entities;
|
|
42
|
+
if (c.core) core.add(cart);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { reg, core };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LiveRegistryState = {
|
|
49
|
+
registry: BulkRegistry;
|
|
50
|
+
carts: string[];
|
|
51
|
+
// Cartridge slugs flagged `core` by the daemon. The Data tab keeps `auth`
|
|
52
|
+
// visible but tucks the rest of these behind a "more cartridges" menu so
|
|
53
|
+
// deployed (user) cartridges are what's shown by default.
|
|
54
|
+
coreCarts: Set<string>;
|
|
55
|
+
loading: boolean;
|
|
56
|
+
error: string | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function useLiveBulkRegistry(): LiveRegistryState {
|
|
60
|
+
const { apiBaseUrl } = useDevToolsConfig();
|
|
61
|
+
const [registry, setRegistry] = useState<BulkRegistry>({});
|
|
62
|
+
const [coreCarts, setCoreCarts] = useState<Set<string>>(new Set());
|
|
63
|
+
const [loading, setLoading] = useState(true);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const ac = new AbortController();
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
setLoading(true);
|
|
70
|
+
setError(null);
|
|
71
|
+
(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetch(`${apiBaseUrl}/_console/contracts`, {
|
|
74
|
+
signal: ac.signal,
|
|
75
|
+
credentials: "include",
|
|
76
|
+
headers: authHeaders(),
|
|
77
|
+
});
|
|
78
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
79
|
+
const data = (await r.json()) as Contract[];
|
|
80
|
+
if (!Array.isArray(data)) throw new Error("malformed contracts response");
|
|
81
|
+
if (cancelled) return;
|
|
82
|
+
const { reg, core } = buildRegistry(data);
|
|
83
|
+
setRegistry(reg);
|
|
84
|
+
setCoreCarts(core);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (cancelled || (e as { name?: string }).name === "AbortError") return;
|
|
87
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
88
|
+
} finally {
|
|
89
|
+
if (!cancelled) setLoading(false);
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
return () => {
|
|
93
|
+
cancelled = true;
|
|
94
|
+
ac.abort();
|
|
95
|
+
};
|
|
96
|
+
}, [apiBaseUrl]);
|
|
97
|
+
|
|
98
|
+
const carts = Object.keys(registry).sort();
|
|
99
|
+
return { registry, carts, coreCarts, loading, error };
|
|
100
|
+
}
|