datool 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +218 -0
  2. package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  3. package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  4. package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  5. package/client-dist/assets/index-BeRNeRUq.css +1 -0
  6. package/client-dist/assets/index-uoZ4c_I8.js +164 -0
  7. package/client-dist/index.html +13 -0
  8. package/index.html +12 -0
  9. package/package.json +55 -0
  10. package/src/client/App.tsx +885 -0
  11. package/src/client/components/connection-status.tsx +43 -0
  12. package/src/client/components/data-table-cell.tsx +235 -0
  13. package/src/client/components/data-table-col-icon.tsx +73 -0
  14. package/src/client/components/data-table-header-col.tsx +225 -0
  15. package/src/client/components/data-table-search-input.tsx +729 -0
  16. package/src/client/components/data-table.tsx +2014 -0
  17. package/src/client/components/stream-controls.tsx +157 -0
  18. package/src/client/components/theme-provider.tsx +230 -0
  19. package/src/client/components/ui/button.tsx +68 -0
  20. package/src/client/components/ui/combobox.tsx +308 -0
  21. package/src/client/components/ui/context-menu.tsx +261 -0
  22. package/src/client/components/ui/dropdown-menu.tsx +267 -0
  23. package/src/client/components/ui/input-group.tsx +153 -0
  24. package/src/client/components/ui/input.tsx +19 -0
  25. package/src/client/components/ui/textarea.tsx +18 -0
  26. package/src/client/components/viewer-settings.tsx +185 -0
  27. package/src/client/index.css +192 -0
  28. package/src/client/lib/data-table-search.ts +750 -0
  29. package/src/client/lib/datool-icons.ts +37 -0
  30. package/src/client/lib/datool-url-state.ts +159 -0
  31. package/src/client/lib/filterable-table.ts +146 -0
  32. package/src/client/lib/table-search-persistence.ts +94 -0
  33. package/src/client/lib/utils.ts +6 -0
  34. package/src/client/main.tsx +14 -0
  35. package/src/index.ts +19 -0
  36. package/src/node/cli.ts +54 -0
  37. package/src/node/config.ts +231 -0
  38. package/src/node/lines.ts +82 -0
  39. package/src/node/runtime.ts +102 -0
  40. package/src/node/server.ts +403 -0
  41. package/src/node/sources/command.ts +82 -0
  42. package/src/node/sources/file.ts +116 -0
  43. package/src/node/sources/ssh.ts +59 -0
  44. package/src/shared/columns.ts +41 -0
  45. package/src/shared/types.ts +188 -0
@@ -0,0 +1,403 @@
1
+ import fs from "fs"
2
+ import fsPromises from "fs/promises"
3
+ import path from "path"
4
+
5
+ import { getStreamFromConfig, loadDatoolConfig, toClientConfig } from "./config"
6
+ import { openStreamRuntime } from "./runtime"
7
+ import type {
8
+ DatoolActionRequest,
9
+ DatoolActionRowChange,
10
+ DatoolActionResolveResult,
11
+ DatoolActionResponse,
12
+ DatoolConfig,
13
+ DatoolSseErrorEvent,
14
+ } from "../shared/types"
15
+
16
+ type StartServerOptions = {
17
+ configPath?: string
18
+ cwd?: string
19
+ host?: string
20
+ port?: number
21
+ }
22
+
23
+ export type DatoolServerHandle = {
24
+ config: DatoolConfig
25
+ configPath: string
26
+ host: string
27
+ port: number
28
+ stop: () => void
29
+ url: string
30
+ }
31
+
32
+ const SSE_HEADERS = {
33
+ "Cache-Control": "no-cache, no-transform",
34
+ Connection: "keep-alive",
35
+ "Content-Type": "text/event-stream; charset=utf-8",
36
+ } as const
37
+
38
+ function packageRootFromImportMeta() {
39
+ return path.resolve(import.meta.dir, "..", "..")
40
+ }
41
+
42
+ async function loadClientIndexHtml(packageRoot: string) {
43
+ const indexPath = path.join(packageRoot, "client-dist", "index.html")
44
+
45
+ return fsPromises.readFile(indexPath, "utf8")
46
+ }
47
+
48
+ function getClientAssetPath(packageRoot: string, pathname: string) {
49
+ const clientRoot = path.join(packageRoot, "client-dist")
50
+ const absolutePath = path.resolve(clientRoot, pathname.replace(/^\/+/, ""))
51
+
52
+ if (!absolutePath.startsWith(clientRoot)) {
53
+ return null
54
+ }
55
+
56
+ return absolutePath
57
+ }
58
+
59
+ function jsonResponse(body: unknown, status = 200) {
60
+ return Response.json(body, {
61
+ status,
62
+ })
63
+ }
64
+
65
+ function toErrorPayload(error: unknown): DatoolSseErrorEvent {
66
+ if (error instanceof Error) {
67
+ return {
68
+ message: error.message,
69
+ }
70
+ }
71
+
72
+ return {
73
+ message: String(error),
74
+ }
75
+ }
76
+
77
+ function encodeSseEvent(event: string, data: unknown) {
78
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
79
+ }
80
+
81
+ function isRecord(value: unknown): value is Record<string, unknown> {
82
+ return Boolean(value) && typeof value === "object"
83
+ }
84
+
85
+ function toActionResponsePayload(
86
+ result: DatoolActionResolveResult<Record<string, unknown>>,
87
+ rowCount: number
88
+ ): DatoolActionResponse {
89
+ if (result === undefined || result === true) {
90
+ return {}
91
+ }
92
+
93
+ if (result === null || result === false) {
94
+ return {
95
+ rowChanges: Array<boolean>(rowCount).fill(false),
96
+ }
97
+ }
98
+
99
+ if (Array.isArray(result)) {
100
+ if (result.length === 0) {
101
+ return {}
102
+ }
103
+
104
+ if (result.length !== rowCount) {
105
+ throw new Error(
106
+ `Action results must return either an empty array or exactly ${rowCount} item${rowCount === 1 ? "" : "s"}.`
107
+ )
108
+ }
109
+
110
+ return {
111
+ rowChanges: result.map<boolean | Record<string, unknown>>((change, index) =>
112
+ normalizeActionRowChange(change, index)
113
+ ),
114
+ }
115
+ }
116
+
117
+ throw new Error(
118
+ "Action results must return true, false, null, undefined, or an array of row changes."
119
+ )
120
+ }
121
+
122
+ function normalizeActionRowChange(
123
+ change: DatoolActionRowChange<Record<string, unknown>>,
124
+ index: number
125
+ ) {
126
+ if (change === undefined || change === true) {
127
+ return true
128
+ }
129
+
130
+ if (change === null || change === false) {
131
+ return false
132
+ }
133
+
134
+ if (isRecord(change)) {
135
+ return change
136
+ }
137
+
138
+ throw new Error(
139
+ `Action result at index ${index} must be a row object, true, false, null, or undefined.`
140
+ )
141
+ }
142
+
143
+ async function resolveActionResponsePayload(
144
+ result: DatoolActionResolveResult<Record<string, unknown>>,
145
+ rowCount: number
146
+ ) {
147
+ try {
148
+ return toActionResponsePayload(result, rowCount)
149
+ } catch (error) {
150
+ return {
151
+ error: error instanceof Error ? error.message : String(error),
152
+ }
153
+ }
154
+ }
155
+
156
+ async function createActionResponse(
157
+ config: DatoolConfig,
158
+ streamId: string,
159
+ actionId: string,
160
+ query: URLSearchParams,
161
+ request: Request
162
+ ) {
163
+ const stream = getStreamFromConfig(config, streamId)
164
+
165
+ if (!stream) {
166
+ return jsonResponse(
167
+ {
168
+ error: `Unknown stream "${streamId}".`,
169
+ },
170
+ 404
171
+ )
172
+ }
173
+
174
+ const action = stream.actions?.[actionId]
175
+
176
+ if (!action) {
177
+ return jsonResponse(
178
+ {
179
+ error: `Unknown action "${actionId}" for stream "${streamId}".`,
180
+ },
181
+ 404
182
+ )
183
+ }
184
+
185
+ let body: DatoolActionRequest
186
+
187
+ try {
188
+ body = (await request.json()) as DatoolActionRequest
189
+ } catch {
190
+ return jsonResponse(
191
+ {
192
+ error: "Invalid JSON body.",
193
+ },
194
+ 400
195
+ )
196
+ }
197
+
198
+ if (!Array.isArray(body.rows) || body.rows.length === 0) {
199
+ return jsonResponse(
200
+ {
201
+ error: "Action requests must include a non-empty rows array.",
202
+ },
203
+ 400
204
+ )
205
+ }
206
+
207
+ try {
208
+ const result = await action.resolve({
209
+ actionId,
210
+ query,
211
+ rows: body.rows,
212
+ streamId,
213
+ })
214
+
215
+ const payload = await resolveActionResponsePayload(result, body.rows.length)
216
+
217
+ if ("error" in payload) {
218
+ return jsonResponse(
219
+ {
220
+ error: payload.error,
221
+ },
222
+ 500
223
+ )
224
+ }
225
+
226
+ return jsonResponse(payload)
227
+ } catch (error) {
228
+ return jsonResponse(
229
+ {
230
+ error: error instanceof Error ? error.message : String(error),
231
+ },
232
+ 500
233
+ )
234
+ }
235
+ }
236
+
237
+ function createSseResponse(
238
+ config: DatoolConfig,
239
+ streamId: string,
240
+ query: URLSearchParams,
241
+ requestSignal: AbortSignal
242
+ ) {
243
+ const stream = getStreamFromConfig(config, streamId)
244
+
245
+ if (!stream) {
246
+ return jsonResponse(
247
+ {
248
+ error: `Unknown stream "${streamId}".`,
249
+ },
250
+ 404
251
+ )
252
+ }
253
+
254
+ const responseStream = new ReadableStream<Uint8Array>({
255
+ start(controller) {
256
+ const encoder = new TextEncoder()
257
+ const abortController = new AbortController()
258
+
259
+ const forwardAbort = () => abortController.abort()
260
+
261
+ requestSignal.addEventListener("abort", forwardAbort, {
262
+ once: true,
263
+ })
264
+
265
+ const send = (event: string, payload: unknown) => {
266
+ controller.enqueue(encoder.encode(encodeSseEvent(event, payload)))
267
+ }
268
+
269
+ const heartbeat = setInterval(() => {
270
+ if (!abortController.signal.aborted) {
271
+ send("heartbeat", { ok: true })
272
+ }
273
+ }, 15_000)
274
+
275
+ void openStreamRuntime(
276
+ streamId,
277
+ stream,
278
+ query,
279
+ abortController.signal,
280
+ {
281
+ async onError(error) {
282
+ send("error", toErrorPayload(error))
283
+ },
284
+ async onRow(payload) {
285
+ send("row", payload)
286
+ },
287
+ }
288
+ )
289
+ .catch((error) => {
290
+ send("error", toErrorPayload(error))
291
+ })
292
+ .finally(() => {
293
+ clearInterval(heartbeat)
294
+ requestSignal.removeEventListener("abort", forwardAbort)
295
+ try {
296
+ controller.close()
297
+ } catch {
298
+ // The client may have already closed the SSE stream.
299
+ }
300
+ })
301
+ },
302
+ cancel() {
303
+ // The request signal already drives stream shutdown.
304
+ },
305
+ })
306
+
307
+ return new Response(responseStream, {
308
+ headers: SSE_HEADERS,
309
+ })
310
+ }
311
+
312
+ export async function startDatoolServer(
313
+ options: StartServerOptions = {}
314
+ ): Promise<DatoolServerHandle> {
315
+ const cwd = options.cwd ?? process.cwd()
316
+ const packageRoot = packageRootFromImportMeta()
317
+ const { config, configPath } = await loadDatoolConfig({
318
+ configPath: options.configPath,
319
+ cwd,
320
+ })
321
+ const host = options.host ?? config.server?.host ?? "127.0.0.1"
322
+ const port = options.port ?? config.server?.port ?? 3210
323
+ const indexHtml = await loadClientIndexHtml(packageRoot)
324
+ const server = Bun.serve({
325
+ async fetch(request) {
326
+ const url = new URL(request.url)
327
+
328
+ if (url.pathname === "/api/config") {
329
+ return jsonResponse(toClientConfig(config))
330
+ }
331
+
332
+ if (
333
+ url.pathname.startsWith("/api/streams/") &&
334
+ url.pathname.endsWith("/events")
335
+ ) {
336
+ const streamId = decodeURIComponent(
337
+ url.pathname
338
+ .slice("/api/streams/".length, -"/events".length)
339
+ .replace(/\/+$/, "")
340
+ )
341
+ const query = new URLSearchParams(url.searchParams)
342
+
343
+ query.delete("stream")
344
+
345
+ return createSseResponse(config, streamId, query, request.signal)
346
+ }
347
+
348
+ if (
349
+ request.method === "POST" &&
350
+ url.pathname.startsWith("/api/streams/") &&
351
+ url.pathname.includes("/actions/")
352
+ ) {
353
+ const pathMatch = url.pathname.match(
354
+ /^\/api\/streams\/(.+?)\/actions\/(.+?)\/?$/
355
+ )
356
+
357
+ if (!pathMatch) {
358
+ return new Response("Not found", {
359
+ status: 404,
360
+ })
361
+ }
362
+
363
+ const streamId = decodeURIComponent(pathMatch[1] ?? "")
364
+ const actionId = decodeURIComponent(pathMatch[2] ?? "")
365
+ const query = new URLSearchParams(url.searchParams)
366
+
367
+ query.delete("stream")
368
+
369
+ return createActionResponse(config, streamId, actionId, query, request)
370
+ }
371
+
372
+ if (url.pathname === "/" || url.pathname === "/index.html") {
373
+ return new Response(indexHtml, {
374
+ headers: {
375
+ "Content-Type": "text/html; charset=utf-8",
376
+ },
377
+ })
378
+ }
379
+
380
+ const assetPath = getClientAssetPath(packageRoot, url.pathname)
381
+
382
+ if (assetPath && fs.existsSync(assetPath)) {
383
+ return new Response(Bun.file(assetPath))
384
+ }
385
+
386
+ return new Response("Not found", {
387
+ status: 404,
388
+ })
389
+ },
390
+ hostname: host,
391
+ port,
392
+ })
393
+ const resolvedPort = server.port ?? port
394
+
395
+ return {
396
+ config,
397
+ configPath,
398
+ host,
399
+ port: resolvedPort,
400
+ stop: () => server.stop(true),
401
+ url: `http://${host}:${resolvedPort}`,
402
+ }
403
+ }
@@ -0,0 +1,82 @@
1
+ import { readLinesFromStream } from "../lines"
2
+ import type { DatoolSource } from "../../shared/types"
3
+
4
+ type Resolver<T> = T | ((context: { query: URLSearchParams }) => T)
5
+
6
+ export type CommandSourceOptions = {
7
+ args?: Resolver<string[]>
8
+ command: Resolver<string>
9
+ cwd?: Resolver<string>
10
+ env?: Resolver<Record<string, string | undefined>>
11
+ }
12
+
13
+ function resolveValue<T>(value: Resolver<T>, query: URLSearchParams) {
14
+ if (typeof value === "function") {
15
+ return (value as (context: { query: URLSearchParams }) => T)({
16
+ query,
17
+ })
18
+ }
19
+
20
+ return value
21
+ }
22
+
23
+ function toSpawnArgs(options: CommandSourceOptions, query: URLSearchParams) {
24
+ const command = resolveValue(options.command, query)
25
+ const args = options.args ? resolveValue(options.args, query) : undefined
26
+
27
+ if (args && args.length > 0) {
28
+ return [command, ...args]
29
+ }
30
+
31
+ return ["/bin/sh", "-lc", command]
32
+ }
33
+
34
+ export function commandSource(options: CommandSourceOptions): DatoolSource {
35
+ return {
36
+ async open(context) {
37
+ const cmd = toSpawnArgs(options, context.query)
38
+ const env = options.env ? resolveValue(options.env, context.query) : undefined
39
+ const cwd = options.cwd ? resolveValue(options.cwd, context.query) : undefined
40
+ const child = Bun.spawn(cmd, {
41
+ cwd,
42
+ env,
43
+ stderr: "pipe",
44
+ stdout: "pipe",
45
+ })
46
+
47
+ const abortChild = () => {
48
+ try {
49
+ child.kill()
50
+ } catch {
51
+ // Ignore process termination races during shutdown.
52
+ }
53
+ }
54
+
55
+ context.signal.addEventListener("abort", abortChild, {
56
+ once: true,
57
+ })
58
+
59
+ try {
60
+ await Promise.all([
61
+ readLinesFromStream(child.stdout, {
62
+ onLine: context.emit,
63
+ signal: context.signal,
64
+ }),
65
+ child.exited,
66
+ ])
67
+
68
+ const exitCode = await child.exited
69
+
70
+ if (exitCode !== 0 && !context.signal.aborted) {
71
+ const stderrText = await new Response(child.stderr).text()
72
+
73
+ throw new Error(
74
+ stderrText.trim() || `Command exited with code ${exitCode}.`
75
+ )
76
+ }
77
+ } finally {
78
+ context.signal.removeEventListener("abort", abortChild)
79
+ }
80
+ },
81
+ }
82
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "fs/promises"
2
+
3
+ import { waitForDelay } from "../lines"
4
+ import type { DatoolSource } from "../../shared/types"
5
+
6
+ type Resolver<T> = T | ((context: { query: URLSearchParams }) => T)
7
+
8
+ export type FileSourceOptions = {
9
+ historyParam?: string
10
+ path: Resolver<string>
11
+ pollIntervalMs?: number
12
+ }
13
+
14
+ function resolveValue<T>(value: Resolver<T>, query: URLSearchParams) {
15
+ if (typeof value === "function") {
16
+ return (value as (context: { query: URLSearchParams }) => T)({
17
+ query,
18
+ })
19
+ }
20
+
21
+ return value
22
+ }
23
+
24
+ function toHistoryLineCount(query: URLSearchParams, historyParam: string) {
25
+ const rawValue = query.get(historyParam)
26
+
27
+ if (!rawValue) {
28
+ return 0
29
+ }
30
+
31
+ const parsedValue = Number.parseInt(rawValue, 10)
32
+
33
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
34
+ return 0
35
+ }
36
+
37
+ return parsedValue
38
+ }
39
+
40
+ function normalizeLines(content: string) {
41
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
42
+ }
43
+
44
+ async function emitHistoryLines(
45
+ filePath: string,
46
+ lineCount: number,
47
+ emit: (line: string) => void
48
+ ) {
49
+ if (lineCount <= 0) {
50
+ return
51
+ }
52
+
53
+ const content = await fs.readFile(filePath, "utf8")
54
+ const lines = normalizeLines(content).filter(Boolean)
55
+
56
+ for (const line of lines.slice(-lineCount)) {
57
+ emit(line)
58
+ }
59
+ }
60
+
61
+ async function readAppendedText(filePath: string, start: number, end: number) {
62
+ const fileHandle = await fs.open(filePath, "r")
63
+
64
+ try {
65
+ const length = end - start
66
+ const buffer = Buffer.alloc(length)
67
+
68
+ await fileHandle.read(buffer, 0, length, start)
69
+
70
+ return buffer.toString("utf8")
71
+ } finally {
72
+ await fileHandle.close()
73
+ }
74
+ }
75
+
76
+ export function fileSource(options: FileSourceOptions): DatoolSource {
77
+ return {
78
+ async open(context) {
79
+ const historyParam = options.historyParam ?? "history"
80
+ const pollIntervalMs = options.pollIntervalMs ?? 250
81
+ const filePath = resolveValue(options.path, context.query)
82
+ const historyLineCount = toHistoryLineCount(context.query, historyParam)
83
+ let stat = await fs.stat(filePath)
84
+ let position = stat.size
85
+ let remainder = ""
86
+
87
+ await emitHistoryLines(filePath, historyLineCount, context.emit)
88
+
89
+ while (!context.signal.aborted) {
90
+ stat = await fs.stat(filePath)
91
+
92
+ if (stat.size < position) {
93
+ position = 0
94
+ remainder = ""
95
+ }
96
+
97
+ if (stat.size > position) {
98
+ const appendedText = await readAppendedText(filePath, position, stat.size)
99
+ const normalized = (remainder + appendedText).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
100
+ const parts = normalized.split("\n")
101
+
102
+ remainder = parts.pop() ?? ""
103
+ position = stat.size
104
+
105
+ for (const line of parts) {
106
+ if (line.length > 0) {
107
+ context.emit(line)
108
+ }
109
+ }
110
+ }
111
+
112
+ await waitForDelay(pollIntervalMs, context.signal)
113
+ }
114
+ },
115
+ }
116
+ }
@@ -0,0 +1,59 @@
1
+ import { commandSource } from "./command"
2
+ import type { DatoolSource } from "../../shared/types"
3
+
4
+ type Resolver<T> = T | ((context: { query: URLSearchParams }) => T)
5
+
6
+ export type SshSourceOptions = {
7
+ command: Resolver<string>
8
+ host: string
9
+ identityFile?: string
10
+ options?: Record<string, string>
11
+ port?: number
12
+ user?: string
13
+ }
14
+
15
+ function resolveValue<T>(value: Resolver<T>, query: URLSearchParams) {
16
+ if (typeof value === "function") {
17
+ return (value as (context: { query: URLSearchParams }) => T)({
18
+ query,
19
+ })
20
+ }
21
+
22
+ return value
23
+ }
24
+
25
+ export function buildSshArgs(
26
+ options: SshSourceOptions,
27
+ query: URLSearchParams
28
+ ) {
29
+ const sshArgs: string[] = []
30
+
31
+ if (options.port) {
32
+ sshArgs.push("-p", String(options.port))
33
+ }
34
+
35
+ if (options.identityFile) {
36
+ sshArgs.push("-i", options.identityFile)
37
+ }
38
+
39
+ for (const [key, value] of Object.entries(options.options ?? {})) {
40
+ sshArgs.push("-o", `${key}=${value}`)
41
+ }
42
+
43
+ const target = options.user ? `${options.user}@${options.host}` : options.host
44
+
45
+ sshArgs.push(target, resolveValue(options.command, query))
46
+
47
+ return sshArgs
48
+ }
49
+
50
+ export function sshSource(options: SshSourceOptions): DatoolSource {
51
+ return {
52
+ open(context) {
53
+ return commandSource({
54
+ args: buildSshArgs(options, context.query),
55
+ command: "ssh",
56
+ }).open(context)
57
+ },
58
+ }
59
+ }
@@ -0,0 +1,41 @@
1
+ import type { DatoolColumn } from "./types"
2
+
3
+ export function resolveDatoolColumnId(
4
+ column: DatoolColumn,
5
+ index: number
6
+ ) {
7
+ return (
8
+ column.id ??
9
+ column.accessorKey ??
10
+ (column.header
11
+ ? column.header.toLowerCase().replace(/\s+/g, "-")
12
+ : `column-${index}`)
13
+ )
14
+ }
15
+
16
+ export function getValueAtPath(
17
+ value: Record<string, unknown>,
18
+ accessorKey: string
19
+ ): unknown {
20
+ if (!accessorKey.includes(".")) {
21
+ return value[accessorKey]
22
+ }
23
+
24
+ return accessorKey
25
+ .split(".")
26
+ .reduce<unknown>((currentValue, segment) => {
27
+ if (
28
+ currentValue === null ||
29
+ currentValue === undefined ||
30
+ typeof currentValue !== "object"
31
+ ) {
32
+ return undefined
33
+ }
34
+
35
+ return (currentValue as Record<string, unknown>)[segment]
36
+ }, value)
37
+ }
38
+
39
+ export function isNestedAccessorKey(accessorKey: string) {
40
+ return accessorKey.includes(".")
41
+ }