dbtour 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface Io {
10
10
  load?: (source: SourceConfig) => Promise<Source>;
11
11
  /** How an add-on is loaded; tests hand in their own. */
12
12
  loadAddon?: (addon: AddonConfig) => Promise<Addon>;
13
+ /** When serve stops; Ctrl+C unless a test hands in its own. */
14
+ stop?: Promise<void>;
13
15
  /** Where mcp reads requests and writes answers; process.stdin and process.stdout unless a test hands in its own. */
14
16
  stdin?: Readable;
15
17
  stdout?: Writable;
package/dist/cli.js CHANGED
@@ -18,6 +18,8 @@ import { emptyModel, scope, validate } from "./model.js";
18
18
  import { applyNotes, parseNotes } from "./notes.js";
19
19
  import { answerLine } from "./mcp.js";
20
20
  import { checkQuery, clampLimit, defaultTimeoutMs, resultText } from "./sql.js";
21
+ import { startServer } from "./serve.js";
22
+ import { randomBytes } from 'node:crypto';
21
23
  import { loadAddon, loadSource } from "./plugins.js";
22
24
  import { renderMarkdown } from "./render/markdown.js";
23
25
  const help = `Usage: dbtour <command>
@@ -30,12 +32,14 @@ const help = `Usage: dbtour <command>
30
32
  on stdin and stdout: claude mcp add dbtour -- npx dbtour mcp
31
33
  sql "<query>" run one SELECT through a source's reader, a read-only login of its own,
32
34
  rolled back, at most --limit rows (100); --source <name>, --json
35
+ serve [--port 4321] the rendered page on this machine; its SQL editor runs queries through the
36
+ readers, as sql does
33
37
 
34
38
  Set the connection string in the environment variable ${configFile} names, never in the file.`;
35
39
  export async function main(argv, io = { cwd: process.cwd(), log: console.log, error: console.error }) {
36
40
  const { values, positionals } = parseArgs({
37
41
  args: argv,
38
- options: { help: { type: 'boolean', short: 'h' }, source: { type: 'string' }, limit: { type: 'string' }, json: { type: 'boolean' } },
42
+ options: { help: { type: 'boolean', short: 'h' }, source: { type: 'string' }, limit: { type: 'string' }, json: { type: 'boolean' }, port: { type: 'string' } },
39
43
  allowPositionals: true,
40
44
  strict: false,
41
45
  });
@@ -54,6 +58,8 @@ export async function main(argv, io = { cwd: process.cwd(), log: console.log, er
54
58
  return await render(io);
55
59
  case 'mcp':
56
60
  return await mcp(positionals[1] === undefined ? io : { ...io, cwd: resolve(io.cwd, positionals[1]) });
61
+ case 'serve':
62
+ return await serve(io, typeof values.port === 'string' ? Number(values.port) : defaultPort);
57
63
  case 'sql':
58
64
  return await sql(io, positionals[1], {
59
65
  source: typeof values.source === 'string' ? values.source : undefined,
@@ -222,6 +228,42 @@ async function mcp(io) {
222
228
  }
223
229
  return 0;
224
230
  }
231
+ /** dbtour serve's port unless --port says otherwise: one an editor's own dev servers seldom take. */
232
+ const defaultPort = 4321;
233
+ /**
234
+ * The rendered folder on 127.0.0.1, with a door for its SQL editor: a query posted by the page runs as dbtour
235
+ * sql runs it. A token made fresh each time is handed to the page and required on every query.
236
+ */
237
+ async function serve(io, port) {
238
+ const config = readConfig(io);
239
+ const root = join(io.cwd, config.out);
240
+ if (!existsSync(join(root, 'index.html')))
241
+ throw missing(`No ${join(config.out, 'index.html')} to serve: add explorer to addons in ${configFile}, run dbtour render, then dbtour serve again`);
242
+ const readers = config.sources.filter((s) => s.reader !== undefined);
243
+ const token = randomBytes(24).toString('base64url');
244
+ let server;
245
+ try {
246
+ server = await startServer({
247
+ root,
248
+ port,
249
+ token,
250
+ sources: config.sources.map((s) => ({ name: s.name, reader: s.reader !== undefined })),
251
+ run: readers.length
252
+ ? async (text, source, limit) => ({ ...(await runQuery(io, config, text, source, limit)), source: pickSource(config, source).name })
253
+ : undefined,
254
+ });
255
+ }
256
+ catch (error) {
257
+ if (error.code === 'EADDRINUSE')
258
+ throw new Error(`Port ${port} is taken: run dbtour serve --port ${port + 1}`, { cause: error });
259
+ throw error;
260
+ }
261
+ io.log(`Serving ${config.out} at ${server.url}`);
262
+ io.log(readers.length ? `Queries run through ${readers.map((s) => `${s.name}'s reader`).join(', ')}. Ctrl+C to stop.` : `No source has a reader, so the page can be read but runs no query: add reader to a source in ${configFile}. Ctrl+C to stop.`);
263
+ await (io.stop ?? new Promise((done) => process.once('SIGINT', () => done())));
264
+ await server.close();
265
+ return 0;
266
+ }
225
267
  async function sql(io, text, options) {
226
268
  if (text === undefined || text.trim() === '') {
227
269
  io.error('Give the query to run: dbtour sql "SELECT TOP (10) * FROM dbo.Orders"');
@@ -0,0 +1,24 @@
1
+ import type { QueryResult } from './sql.ts';
2
+ export interface Served {
3
+ /** The folder render wrote: index.html and the Markdown beside it. */
4
+ root: string;
5
+ /** 0 for any free port. */
6
+ port: number;
7
+ /** The session's secret, given to the page in a meta tag and required on every query. */
8
+ token: string;
9
+ /** The sources, and whether each has a reader: what the page shows, never a connection string. */
10
+ sources: {
11
+ name: string;
12
+ reader: boolean;
13
+ }[];
14
+ /** Runs one query through a source's reader; absent when no source has one. */
15
+ run?: (sql: string, source: string | undefined, limit: number | undefined) => Promise<QueryResult & {
16
+ source: string;
17
+ }>;
18
+ }
19
+ /** The address it listens on: this machine alone. */
20
+ export declare const serveHost = "127.0.0.1";
21
+ export declare function startServer(options: Served): Promise<{
22
+ url: string;
23
+ close: () => Promise<void>;
24
+ }>;
package/dist/serve.js ADDED
@@ -0,0 +1,129 @@
1
+ // dbtour serve: the rendered folder on this machine, and the one door the page has to the database. The page
2
+ // runs a query by posting it here, and it runs as dbtour sql does: checked, through the source's reader, in a
3
+ // transaction that is rolled back. Only this machine can reach it (127.0.0.1), and only this server's own
4
+ // page can ask: the request must carry the session's token, which the page is given when served, and say it
5
+ // comes from this server (Host and Origin), so another website, or a name rebound to this address, cannot.
6
+ import { createServer } from 'node:http';
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { extname, join, normalize, resolve, sep } from 'node:path';
9
+ /** The address it listens on: this machine alone. */
10
+ export const serveHost = '127.0.0.1';
11
+ /** A query and its options are a few lines; anything past this is not one, and is refused unread. */
12
+ const maxBodyBytes = 100 * 1024;
13
+ const types = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.md': 'text/markdown; charset=utf-8',
16
+ '.json': 'application/json; charset=utf-8',
17
+ '.svg': 'image/svg+xml',
18
+ '.png': 'image/png',
19
+ '.css': 'text/css; charset=utf-8',
20
+ '.js': 'text/javascript; charset=utf-8',
21
+ };
22
+ export async function startServer(options) {
23
+ let origins = new Set();
24
+ let hosts = new Set();
25
+ const server = createServer((request, response) => {
26
+ handle(request, response, options, hosts, origins).catch((error) => send(response, 500, { error: error instanceof Error ? error.message : String(error) }));
27
+ });
28
+ await new Promise((done, fail) => {
29
+ server.once('error', fail);
30
+ server.listen(options.port, serveHost, () => done());
31
+ });
32
+ const port = server.address().port;
33
+ hosts = new Set([`${serveHost}:${port}`, `localhost:${port}`]);
34
+ origins = new Set([...hosts].map((h) => `http://${h}`));
35
+ return {
36
+ url: `http://${serveHost}:${port}`,
37
+ close: () => new Promise((done) => server.close(() => done())),
38
+ };
39
+ }
40
+ async function handle(request, response, options, hosts, origins) {
41
+ // Every route, not only the query: the page carries the token and the model, and the Markdown and
42
+ // schema.json are the schema, none of it for a site whose name was rebound to this address.
43
+ if (!hosts.has(request.headers.host ?? '')) {
44
+ return send(response, 403, { error: 'Open the page at the address dbtour serve printed: this server answers only to its own name.' });
45
+ }
46
+ const path = decodeURIComponent(new URL(request.url ?? '/', 'http://local').pathname);
47
+ if (path === '/api/query')
48
+ return query(request, response, options, hosts, origins);
49
+ if (request.method !== 'GET' && request.method !== 'HEAD')
50
+ return send(response, 405, { error: 'Only the page and its files are here: GET them.' });
51
+ const file = resolve(options.root, normalize(`.${path === '/' ? '/index.html' : path}`));
52
+ // Nothing outside the rendered folder, however the path is written.
53
+ if (file !== resolve(options.root) && !file.startsWith(resolve(options.root) + sep))
54
+ return send(response, 404, { error: 'Not here.' });
55
+ if (!existsSync(file) || !statSync(file).isFile())
56
+ return send(response, 404, { error: 'Not here.' });
57
+ let body = readFileSync(file);
58
+ if (file === join(resolve(options.root), 'index.html'))
59
+ body = withServe(body.toString('utf8'), options);
60
+ response.writeHead(200, { 'content-type': types[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
61
+ response.end(request.method === 'HEAD' ? undefined : body);
62
+ }
63
+ /** The page, told it is served: the token to send with a query, and which sources can run one. */
64
+ function withServe(html, options) {
65
+ const value = JSON.stringify({ token: options.token, sources: options.sources }).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
66
+ const meta = `<meta name="dbtour-serve" content="${value}">`;
67
+ return html.includes('</head>') ? html.replace('</head>', `${meta}</head>`) : meta + html;
68
+ }
69
+ async function query(request, response, options, hosts, origins) {
70
+ if (request.method !== 'POST')
71
+ return send(response, 405, { error: 'POST a query: {"sql": "SELECT …"}.' });
72
+ // The page's own requests name this server; anything else is another site, or a name rebound to this address.
73
+ const origin = request.headers.origin;
74
+ if (!hosts.has(request.headers.host ?? '') || (origin !== undefined && !origins.has(origin)) || request.headers['x-dbtour-token'] !== options.token) {
75
+ return send(response, 403, { error: 'Only the page dbtour serve gave out can run a query here: open it again from the address dbtour serve printed.' });
76
+ }
77
+ const text = await body(request);
78
+ if (text === undefined)
79
+ return send(response, 413, { error: 'That is more than a query: send one SELECT.' });
80
+ let asked;
81
+ try {
82
+ asked = JSON.parse(text);
83
+ }
84
+ catch {
85
+ asked = undefined;
86
+ }
87
+ if (typeof asked !== 'object' || asked === null || Array.isArray(asked) || typeof asked.sql !== 'string') {
88
+ return send(response, 400, { error: 'Send {"sql": "SELECT …"}, with source and limit if you want them.' });
89
+ }
90
+ const { sql, source, limit } = asked;
91
+ if (!options.run) {
92
+ return send(response, 422, { error: 'This page can be read, but no source has a reader, so a query cannot run here: add reader: ${DBTOUR_MSSQL_READER} to a source in dbtour.yaml and start dbtour serve again.' });
93
+ }
94
+ const started = Date.now();
95
+ try {
96
+ const result = await options.run(sql, typeof source === 'string' ? source : undefined, typeof limit === 'number' ? limit : undefined);
97
+ send(response, 200, { ...result, ms: Date.now() - started });
98
+ }
99
+ catch (error) {
100
+ send(response, 422, { error: error instanceof Error ? error.message : String(error) });
101
+ }
102
+ }
103
+ /**
104
+ * The request's body, or nothing when it is larger than any query. Past the limit the rest is read and
105
+ * dropped, not kept, so the connection stays open for the 413 that says why.
106
+ */
107
+ function body(request) {
108
+ return new Promise((done, fail) => {
109
+ const chunks = [];
110
+ let size = 0;
111
+ request.on('data', (chunk) => {
112
+ size += chunk.length;
113
+ if (size <= maxBodyBytes)
114
+ chunks.push(chunk);
115
+ });
116
+ request.on('end', () => done(size > maxBodyBytes ? undefined : Buffer.concat(chunks).toString('utf8')));
117
+ request.on('error', fail);
118
+ });
119
+ }
120
+ /** JSON, with a date as ISO and bytes as hex, as dbtour sql --json writes them. */
121
+ function send(response, status, value) {
122
+ const text = JSON.stringify(value, (_key, v) => {
123
+ if (v && typeof v === 'object' && v.type === 'Buffer')
124
+ return `0x${Buffer.from(v.data).toString('hex')}`;
125
+ return v;
126
+ });
127
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
128
+ response.end(text);
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbtour",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Tour your database and its API: searchable docs, relations and diagrams, generated from the schema, kept in the repo.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,10 @@
32
32
  "./search": {
33
33
  "types": "./dist/search.d.ts",
34
34
  "default": "./dist/search.js"
35
+ },
36
+ "./sql": {
37
+ "types": "./dist/sql.d.ts",
38
+ "default": "./dist/sql.js"
35
39
  }
36
40
  },
37
41
  "files": [