duckfn-docs-kit 0.2.0 → 0.3.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/AGENTS.md +234 -730
- package/README.md +7 -5
- package/bin/sql-verify.mjs +12 -12
- package/dist/index.js +1 -1
- package/dist/{register-DKLiYs-F.js → register-CALCwFBv.js} +32 -23
- package/dist/remark.d.ts +1 -1
- package/dist/sql/browserRunner.d.ts +41 -0
- package/dist/sql/browserRunner.js +186 -0
- package/dist/sql/client.js +1 -1
- package/dist/sql/harness.d.ts +59 -0
- package/dist/sql/harness.js +8328 -0
- package/dist/sql/runtime.d.ts +20 -0
- package/dist/sql/verify.d.ts +4 -5
- package/dist/sql/verify.js +36 -49
- package/package.json +3 -2
- package/src/remark.ts +1 -1
- package/src/sql/DfkSql.ts +2 -2
- package/src/sql/browserRunner.ts +402 -0
- package/src/sql/harness.ts +134 -0
- package/src/sql/runtime.ts +44 -0
- package/src/sql/sql.css +1 -1
- package/src/sql/verify.ts +23 -41
- package/dist/sql/nodeRunner.d.ts +0 -51
- package/dist/sql/nodeRunner.js +0 -115
- package/src/sql/nodeRunner.ts +0 -298
package/dist/sql/runtime.d.ts
CHANGED
|
@@ -49,6 +49,26 @@ export type RuntimeState = 'idle' | 'loading' | 'ready' | 'error';
|
|
|
49
49
|
export interface RuntimeOptions {
|
|
50
50
|
/** Let `LOAD` accept an extension whose signature does not verify. */
|
|
51
51
|
allowUnsignedExtensions?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Serve the DuckDB-Wasm engine from explicit same-origin URLs instead of the
|
|
54
|
+
* jsDelivr CDN. Used by the offline SQL verifier (`sql/browserRunner`): the
|
|
55
|
+
* harness passes the locally-served `duckdb-*.wasm` / worker script so a CI
|
|
56
|
+
* run never reaches the network. When set, `#create()` skips
|
|
57
|
+
* `getJsDelivrBundles()`/`selectBundle()` and the cross-origin blob-worker
|
|
58
|
+
* wrapper (the worker is same-origin here, so it is constructed directly).
|
|
59
|
+
*/
|
|
60
|
+
bundle?: LocalBundle;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A locally-served DuckDB-Wasm bundle: absolute same-origin URLs for the
|
|
64
|
+
* engine wasm and its worker script. `pthreadWorker` is only needed for the
|
|
65
|
+
* cross-origin-isolated (COI) bundle; the default non-COI `eh`/`mvp` bundles run
|
|
66
|
+
* single-threaded and leave it unset.
|
|
67
|
+
*/
|
|
68
|
+
export interface LocalBundle {
|
|
69
|
+
mainModule: string;
|
|
70
|
+
mainWorker: string;
|
|
71
|
+
pthreadWorker?: string;
|
|
52
72
|
}
|
|
53
73
|
/** Options for {@link DuckDBRuntime.loadExtension}. */
|
|
54
74
|
export interface LoadExtensionOptions {
|
package/dist/sql/verify.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type WasmPlatform } from './
|
|
1
|
+
import { type WasmPlatform } from './browserRunner';
|
|
2
2
|
export interface VerifyOptions {
|
|
3
3
|
/** Docs site root; defaults to the working directory. */
|
|
4
4
|
siteDir?: string;
|
|
@@ -16,11 +16,10 @@ export interface VerifyOptions {
|
|
|
16
16
|
/** Per-block timeout in milliseconds; a hang is reported instead of blocking CI. */
|
|
17
17
|
timeoutMs?: number;
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* DuckDB's file system is the real one, relative to the working directory.
|
|
19
|
+
* The browser executable that runs the blocks. Defaults to a detected
|
|
20
|
+
* Chrome/Edge; the `DFK_BROWSER` environment variable is the same override.
|
|
22
21
|
*/
|
|
23
|
-
|
|
22
|
+
browser?: string;
|
|
24
23
|
/** Write the full result list here as JSON. */
|
|
25
24
|
reportFile?: string;
|
|
26
25
|
}
|
package/dist/sql/verify.js
CHANGED
|
@@ -1,52 +1,39 @@
|
|
|
1
1
|
import { collectRunnableSql as e, expectsError as t } from "./collect.js";
|
|
2
|
-
import {
|
|
2
|
+
import { BrowserSqlRunner as n } from "./browserRunner.js";
|
|
3
3
|
import { join as r, resolve as i } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { tmpdir as d } from "node:os";
|
|
4
|
+
import { readdirSync as a, statSync as o, writeFileSync as s } from "node:fs";
|
|
6
5
|
//#region src/sql/verify.ts
|
|
7
|
-
var
|
|
8
|
-
async function
|
|
9
|
-
let n = i(t.siteDir ?? process.cwd()),
|
|
6
|
+
var c = ["docs", "i18n"], l = 3e4;
|
|
7
|
+
async function u(t = {}) {
|
|
8
|
+
let n = i(t.siteDir ?? process.cwd()), r = e({
|
|
10
9
|
siteDir: n,
|
|
11
|
-
contentDirs: t.contentDirs ??
|
|
12
|
-
}),
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
let b;
|
|
17
|
-
try {
|
|
18
|
-
b = await h(s, f, t, m);
|
|
19
|
-
} finally {
|
|
20
|
-
process.chdir(_), t.workingDir || c(g, {
|
|
21
|
-
recursive: !0,
|
|
22
|
-
force: !0
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
let x = {
|
|
26
|
-
blocks: b,
|
|
27
|
-
asDeclared: b.filter((e) => e.outcome === "ok" || e.outcome === "error-as-expected"),
|
|
28
|
-
unexpected: b.filter((e) => e.outcome === "unexpected-error" || e.outcome === "unexpected-success")
|
|
10
|
+
contentDirs: t.contentDirs ?? m(n)
|
|
11
|
+
}), a = t.extension, o = await d(r, a ? /^https?:\/\//i.test(a) ? a : i(a) : h(n), t, t.timeoutMs ?? l), c = {
|
|
12
|
+
blocks: o,
|
|
13
|
+
asDeclared: o.filter((e) => e.outcome === "ok" || e.outcome === "error-as-expected"),
|
|
14
|
+
unexpected: o.filter((e) => e.outcome === "unexpected-error" || e.outcome === "unexpected-success")
|
|
29
15
|
};
|
|
30
|
-
return t.reportFile &&
|
|
16
|
+
return t.reportFile && s(t.reportFile, `${JSON.stringify(c, null, 2)}\n`), c;
|
|
31
17
|
}
|
|
32
|
-
async function
|
|
18
|
+
async function d(e, t, r, i) {
|
|
33
19
|
let a = await n.create({
|
|
34
20
|
extension: t,
|
|
35
21
|
platform: r.platform,
|
|
36
|
-
engine: r.engine
|
|
22
|
+
engine: r.engine,
|
|
23
|
+
browser: r.browser
|
|
37
24
|
}), o = [];
|
|
38
25
|
try {
|
|
39
26
|
let t = null;
|
|
40
|
-
for (let n of e) n.file !== t && (t = n.file, await a.newPage()), o.push(await
|
|
27
|
+
for (let n of e) n.file !== t && (t = n.file, await a.newPage()), o.push(await f(a, n, i));
|
|
41
28
|
} finally {
|
|
42
29
|
await a.close();
|
|
43
30
|
}
|
|
44
31
|
return o;
|
|
45
32
|
}
|
|
46
|
-
async function
|
|
33
|
+
async function f(e, n, r) {
|
|
47
34
|
let i = t(n.config);
|
|
48
35
|
try {
|
|
49
|
-
let t = await
|
|
36
|
+
let t = await p(e.run(n.sql), r);
|
|
50
37
|
return {
|
|
51
38
|
file: n.file,
|
|
52
39
|
line: n.line,
|
|
@@ -63,43 +50,43 @@ async function g(e, n, r) {
|
|
|
63
50
|
};
|
|
64
51
|
}
|
|
65
52
|
}
|
|
66
|
-
function
|
|
53
|
+
function p(e, t) {
|
|
67
54
|
let n, r = new Promise((e, r) => {
|
|
68
55
|
n = setTimeout(() => r(/* @__PURE__ */ Error(`timed out after ${t}ms`)), t);
|
|
69
56
|
});
|
|
70
57
|
return Promise.race([e, r]).finally(() => clearTimeout(n));
|
|
71
58
|
}
|
|
72
|
-
function
|
|
59
|
+
function m(e) {
|
|
73
60
|
let t = [];
|
|
74
|
-
|
|
75
|
-
let n = r(e,
|
|
76
|
-
if (
|
|
61
|
+
g(r(e, c[0])) && t.push(c[0]);
|
|
62
|
+
let n = r(e, c[1]);
|
|
63
|
+
if (g(n)) for (let e of a(n)) g(r(n, e, "docusaurus-plugin-content-docs", "current")) && t.push(r("i18n", e, "docusaurus-plugin-content-docs", "current"));
|
|
77
64
|
return t.length > 0 ? t : ["."];
|
|
78
65
|
}
|
|
79
|
-
function
|
|
80
|
-
let t = r(e, "static", "duckdb-extensions"), n =
|
|
66
|
+
function h(e) {
|
|
67
|
+
let t = r(e, "static", "duckdb-extensions"), n = g(t) ? a(t).filter((e) => e.endsWith(".duckdb_extension.wasm")) : [];
|
|
81
68
|
if (n.length === 0) throw Error(`sql/verify: no extension found in ${t} — pass --extension <file|url>, or let the site's extension preload plugin fetch it first`);
|
|
82
69
|
if (n.length > 1) throw Error(`sql/verify: several extensions found in ${t} (${n.join(", ")}) — pass --extension to pick one`);
|
|
83
70
|
return r(t, n[0]);
|
|
84
71
|
}
|
|
85
|
-
function
|
|
86
|
-
return
|
|
72
|
+
function g(e) {
|
|
73
|
+
return o(e, { throwIfNoEntry: !1 })?.isDirectory() ?? !1;
|
|
87
74
|
}
|
|
88
|
-
async function
|
|
89
|
-
let t =
|
|
75
|
+
async function _(e) {
|
|
76
|
+
let t = y(e);
|
|
90
77
|
if (t.help) {
|
|
91
|
-
process.stdout.write(
|
|
78
|
+
process.stdout.write(v);
|
|
92
79
|
return;
|
|
93
80
|
}
|
|
94
|
-
let n = Date.now(), r = await
|
|
81
|
+
let n = Date.now(), r = await u(t), i = ((Date.now() - n) / 1e3).toFixed(1), a = r.blocks.filter((e) => e.outcome === "error-as-expected").length;
|
|
95
82
|
if (t.quiet || (process.stdout.write(`\n${r.blocks.length} block(s) in ${i}s\n`), process.stdout.write(` ${r.asDeclared.length} as declared (${a} erroring on purpose), ${r.unexpected.length} unexpected\n`)), r.unexpected.length > 0) {
|
|
96
83
|
process.stdout.write("unexpected behaviour:\n");
|
|
97
84
|
for (let e of r.unexpected) process.stdout.write(`- ${e.file}:${e.line} [${e.outcome}] :: ${e.detail}\n`);
|
|
98
85
|
process.exitCode = 1;
|
|
99
86
|
}
|
|
100
87
|
}
|
|
101
|
-
var
|
|
102
|
-
function
|
|
88
|
+
var v = "Usage: duckfn-sql-verify [options]\n\nRuns every runnable SQL block of a duckfn docs site in a headless browser\n(DuckDB-Wasm), and checks that each one behaves as its own metadata declares\n(\"expect\": \"error\" for a block that demonstrates a failure).\n\n --site <dir> Docs site root (default: the working directory)\n --content <dir> Content directory, relative to the site root (repeatable;\n default: docs/ plus every i18n/<locale>/… translation)\n --extension <path> The extension to preload: a .duckdb_extension.wasm path,\n or an absolute http(s) URL (default: the single file under\n static/duckdb-extensions/)\n --platform <eh|mvp> DuckDB-Wasm bundle, which must match the extension build\n (default: eh)\n --engine <path> Engine wasm override\n --browser <path> Browser executable (default: a detected Chrome/Edge,\n or the DFK_BROWSER environment variable)\n --timeout <ms> Per-block timeout (default: 30000)\n --report <file> Write the full result list as JSON\n --quiet Only report unexpected behaviour\n --help Show this help\n";
|
|
89
|
+
function y(e) {
|
|
103
90
|
let t = {}, n = [];
|
|
104
91
|
for (let r = 0; r < e.length; r++) {
|
|
105
92
|
let i = e[r], a = () => {
|
|
@@ -123,12 +110,12 @@ function C(e) {
|
|
|
123
110
|
case "--engine":
|
|
124
111
|
t.engine = a();
|
|
125
112
|
break;
|
|
113
|
+
case "--browser":
|
|
114
|
+
t.browser = a();
|
|
115
|
+
break;
|
|
126
116
|
case "--timeout":
|
|
127
117
|
t.timeoutMs = Number(a());
|
|
128
118
|
break;
|
|
129
|
-
case "--working-dir":
|
|
130
|
-
t.workingDir = a();
|
|
131
|
-
break;
|
|
132
119
|
case "--report":
|
|
133
120
|
t.reportFile = a();
|
|
134
121
|
break;
|
|
@@ -145,4 +132,4 @@ function C(e) {
|
|
|
145
132
|
return n.length > 0 && (t.contentDirs = n), t;
|
|
146
133
|
}
|
|
147
134
|
//#endregion
|
|
148
|
-
export {
|
|
135
|
+
export { _ as cliMain, u as verifySqlDocs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "duckfn-docs-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared Docusaurus building blocks (TOC toggle, home-page web components, brand tokens, remark version placeholder) for duckfn-family extension docs sites.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"AGENTS.md"
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
|
-
"build": "vite build && tsc -p tsconfig.build.json",
|
|
48
|
+
"build": "vite build && vite build --config vite.harness.config.ts && tsc -p tsconfig.build.json",
|
|
49
49
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
50
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
51
51
|
"prepack": "npm run build"
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"@visactor/vtable": "^1.26.8",
|
|
63
63
|
"codemirror": "^6.0.2",
|
|
64
64
|
"iconify-icon": "^3.0.3",
|
|
65
|
+
"playwright-core": "^1.63.0",
|
|
65
66
|
"sql-formatter": "^15.9.0"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
package/src/remark.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {Plugin} from 'unified';
|
|
|
15
15
|
export const DEFAULT_VERSION_PLACEHOLDER = '{{DUCKFN_VERSION}}';
|
|
16
16
|
|
|
17
17
|
export interface VersionPlaceholderOptions {
|
|
18
|
-
/** The real version string to substitute in, e.g. `0.0.
|
|
18
|
+
/** The real version string to substitute in, e.g. `0.0.15`. */
|
|
19
19
|
version: string;
|
|
20
20
|
/** Override the token if a site uses a different one. */
|
|
21
21
|
placeholder?: string;
|
package/src/sql/DfkSql.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {el, HTMLElementBase} from '../dom';
|
|
|
18
18
|
* Everything but the result is in the shadow root. The result is a slotted
|
|
19
19
|
* light-DOM sibling because VTable injects a *document-level* stylesheet that a
|
|
20
20
|
* shadow boundary could not host — and it is only created when a query runs,
|
|
21
|
-
* long after hydration, so the light DOM still starts empty (
|
|
21
|
+
* long after hydration, so the light DOM still starts empty (CONVENTIONS.md rule 11).
|
|
22
22
|
* The editor has no such problem: it sits in this shadow root, so CodeMirror's
|
|
23
23
|
* style-mod resolves the root to the same tree its styles are used in.
|
|
24
24
|
*
|
|
@@ -30,7 +30,7 @@ import {el, HTMLElementBase} from '../dom';
|
|
|
30
30
|
* which a renderer builds, so the component keeps the node (and its state) and
|
|
31
31
|
* hands it over through `RenderContext.fullscreenButton`.
|
|
32
32
|
*
|
|
33
|
-
* Content entry is an **attribute seed** (see
|
|
33
|
+
* Content entry is an **attribute seed** (see CONVENTIONS.md rule 5 exception): the
|
|
34
34
|
* `config` / `sql` attributes are read once in `connectedCallback` because the
|
|
35
35
|
* remark-generated JSX cannot hand content through a ref setter. Reading once
|
|
36
36
|
* to initialise is not an attribute→render loop, so the retained-mode contract
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs runnable SQL blocks in a **real browser**, driven from Node with
|
|
3
|
+
* Playwright — the offline successor to the old Node-worker runner
|
|
4
|
+
* (`nodeRunner.ts`, now removed).
|
|
5
|
+
*
|
|
6
|
+
* Why a browser instead of the Node worker
|
|
7
|
+
* ----------------------------------------
|
|
8
|
+
* The Node worker target is not the environment a reader gets: it cannot read
|
|
9
|
+
* remote `http(s)` data (every remote-data example failed with `IO Error: No
|
|
10
|
+
* files found`), and loading the extension forced a loopback server on a
|
|
11
|
+
* Windows-hostile port (the staging path embeds the port, colons are illegal).
|
|
12
|
+
* A browser is the actual product surface: DuckDB-Wasm reads remote files
|
|
13
|
+
* there, and the extension loads same-origin over plain http with none of the
|
|
14
|
+
* staging/port constraints. That is exactly what the docs site ships.
|
|
15
|
+
*
|
|
16
|
+
* Why Playwright, and why `playwright-core`
|
|
17
|
+
* -----------------------------------------
|
|
18
|
+
* Playwright is the mature, maintained way to drive a browser: it owns the
|
|
19
|
+
* protocol, the connect/navigate/evaluate plumbing, auto-waiting, timeouts and
|
|
20
|
+
* crash handling that a hand-rolled driver would have to reinvent. The kit uses
|
|
21
|
+
* the `playwright-core` package (not the full `playwright`) precisely because
|
|
22
|
+
* `playwright-core` never downloads a browser — it launches one you point it
|
|
23
|
+
* at. The runner starts your **system Chrome/Edge** via `executablePath`, so
|
|
24
|
+
* verification stays fully offline. `--browser <path>` / `DFK_BROWSER` override
|
|
25
|
+
* which executable is used; otherwise a small set of well-known paths is probed.
|
|
26
|
+
*
|
|
27
|
+
* How a page maps to a DuckDB instance
|
|
28
|
+
* -------------------------------------
|
|
29
|
+
* `newPage()` navigates to the harness page (`harness.ts`), which builds a
|
|
30
|
+
* fresh `AsyncDuckDB` from the same-origin engine and preloads the extension.
|
|
31
|
+
* Reloading per content file is the isolation boundary (pages never share an
|
|
32
|
+
* instance), while the blocks of one page share its connection — the same "one
|
|
33
|
+
* instance per page, blocks share a connection" model as the site. Files
|
|
34
|
+
* (`COPY … TO`, `dfn_file_write_*`) land in the instance's in-memory file
|
|
35
|
+
* system, readable within the page and gone on reload — the faithful browser
|
|
36
|
+
* behaviour, not a host-directory emulation.
|
|
37
|
+
*/
|
|
38
|
+
import {createServer, type Server} from 'node:http';
|
|
39
|
+
import {createRequire} from 'node:module';
|
|
40
|
+
import {existsSync, readFileSync} from 'node:fs';
|
|
41
|
+
import {basename, dirname, join} from 'node:path';
|
|
42
|
+
import {chromium, type Browser, type Page} from 'playwright-core';
|
|
43
|
+
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
|
|
46
|
+
/** The extension to `LOAD`: a local `.duckdb_extension.wasm` path or an http(s) URL. */
|
|
47
|
+
export interface RunnerOptions {
|
|
48
|
+
extension: string;
|
|
49
|
+
/** DuckDB-Wasm platform bundle; must match how the extension was built. */
|
|
50
|
+
platform?: WasmPlatform;
|
|
51
|
+
/** Engine wasm override; defaults to the platform bundle inside duckdb-wasm. */
|
|
52
|
+
engine?: string;
|
|
53
|
+
/** Browser executable; defaults to a detected Chrome/Edge (`DFK_BROWSER` wins). */
|
|
54
|
+
browser?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WasmPlatform = 'eh' | 'mvp';
|
|
58
|
+
|
|
59
|
+
export interface RunResult {
|
|
60
|
+
rows: number;
|
|
61
|
+
columns: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-platform file names inside `@duckdb/duckdb-wasm/dist`. */
|
|
65
|
+
const PLATFORM_BUNDLES: Record<WasmPlatform, {wasm: string; worker: string}> = {
|
|
66
|
+
eh: {wasm: 'duckdb-eh.wasm', worker: 'duckdb-browser-eh.worker.js'},
|
|
67
|
+
mvp: {wasm: 'duckdb-mvp.wasm', worker: 'duckdb-browser-mvp.worker.js'},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The harness's `__dfkRun` return shape (see `harness.ts`). */
|
|
71
|
+
interface HarnessResult {
|
|
72
|
+
rows: number;
|
|
73
|
+
columns: number;
|
|
74
|
+
error?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The harness's `__dfkQuery` return shape (see `harness.ts`). */
|
|
78
|
+
export interface QueryResult {
|
|
79
|
+
columns: string[];
|
|
80
|
+
rows: Record<string, unknown>[];
|
|
81
|
+
error?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One DuckDB-Wasm instance at a time, driven through a headless browser.
|
|
86
|
+
* Mirrors the surface `verify.ts` used from `WasmSqlRunner`.
|
|
87
|
+
*/
|
|
88
|
+
export class BrowserSqlRunner {
|
|
89
|
+
#server: Server | null = null;
|
|
90
|
+
#browser: Browser | null = null;
|
|
91
|
+
#page: Page | null = null;
|
|
92
|
+
#harnessUrl = '';
|
|
93
|
+
/** Set when the current page failed to initialise; every run then reports it. */
|
|
94
|
+
#pageError: string | null = null;
|
|
95
|
+
|
|
96
|
+
readonly #extensionUrl: string;
|
|
97
|
+
readonly #enginePath: string;
|
|
98
|
+
readonly #workerPath: string;
|
|
99
|
+
readonly #browserExecutable: string;
|
|
100
|
+
readonly #allowUnsigned: boolean;
|
|
101
|
+
|
|
102
|
+
private constructor(options: {
|
|
103
|
+
extensionUrl: string;
|
|
104
|
+
enginePath: string;
|
|
105
|
+
workerPath: string;
|
|
106
|
+
browserExecutable: string;
|
|
107
|
+
allowUnsigned: boolean;
|
|
108
|
+
}) {
|
|
109
|
+
this.#extensionUrl = options.extensionUrl;
|
|
110
|
+
this.#enginePath = options.enginePath;
|
|
111
|
+
this.#workerPath = options.workerPath;
|
|
112
|
+
this.#browserExecutable = options.browserExecutable;
|
|
113
|
+
this.#allowUnsigned = options.allowUnsigned;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static async create(options: RunnerOptions): Promise<BrowserSqlRunner> {
|
|
117
|
+
const platform = options.platform ?? 'eh';
|
|
118
|
+
const bundle = PLATFORM_BUNDLES[platform];
|
|
119
|
+
const distDir = dirname(require.resolve('@duckdb/duckdb-wasm/dist/duckdb-browser.mjs'));
|
|
120
|
+
const enginePath = options.engine ? resolveLocal(options.engine) : join(distDir, bundle.wasm);
|
|
121
|
+
const workerPath = join(distDir, bundle.worker);
|
|
122
|
+
|
|
123
|
+
const remote = /^https?:\/\//i.test(options.extension);
|
|
124
|
+
const extensionUrl = remote ? options.extension : resolveLocal(options.extension);
|
|
125
|
+
|
|
126
|
+
return new BrowserSqlRunner({
|
|
127
|
+
extensionUrl,
|
|
128
|
+
enginePath,
|
|
129
|
+
workerPath,
|
|
130
|
+
browserExecutable: findBrowser(options.browser),
|
|
131
|
+
// The runner is a trusted local loopback: an unsigned dev extension must
|
|
132
|
+
// load, exactly as the site's own config opts in.
|
|
133
|
+
allowUnsigned: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Starts the static server and the browser; idempotent. */
|
|
138
|
+
async #ensureStarted(): Promise<void> {
|
|
139
|
+
if (this.#page) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.#server = await startStaticServer(this.#routes());
|
|
143
|
+
const address = this.#server.address();
|
|
144
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
145
|
+
this.#harnessUrl = `http://127.0.0.1:${port}/harness.html`;
|
|
146
|
+
|
|
147
|
+
// `playwright-core` launches the executable we hand it and manages the
|
|
148
|
+
// browser process and a throwaway profile for us.
|
|
149
|
+
this.#browser = await chromium.launch({
|
|
150
|
+
executablePath: this.#browserExecutable,
|
|
151
|
+
headless: true,
|
|
152
|
+
});
|
|
153
|
+
this.#page = await this.#browser.newPage();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Maps the URL space the harness page needs to everything on disk. */
|
|
157
|
+
#routes(): Routes {
|
|
158
|
+
// A served extension keeps a `/ext/<name>.duckdb_extension.wasm` shape so
|
|
159
|
+
// its base name still names the entry symbol (`<name>_init_c_api`).
|
|
160
|
+
const ext = /^https?:\/\//i.test(this.#extensionUrl)
|
|
161
|
+
? {remoteUrl: this.#extensionUrl}
|
|
162
|
+
: {localFile: this.#extensionUrl, baseName: basename(this.#extensionUrl)};
|
|
163
|
+
return {
|
|
164
|
+
// Resolved through the package's own `exports` wildcard
|
|
165
|
+
// (`./sql/harness` -> `dist/sql/harness.js`), so it finds the built harness
|
|
166
|
+
// whether the runner runs from the workspace symlink or an installed copy.
|
|
167
|
+
harnessScript: require.resolve('duckfn-docs-kit/sql/harness'),
|
|
168
|
+
engine: {file: this.#enginePath, name: basename(this.#enginePath)},
|
|
169
|
+
worker: {file: this.#workerPath, name: basename(this.#workerPath)},
|
|
170
|
+
extension: ext,
|
|
171
|
+
allowUnsigned: this.#allowUnsigned,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Drops the current page state and opens a fresh one: new instance, new connection. */
|
|
176
|
+
async newPage(): Promise<void> {
|
|
177
|
+
await this.#ensureStarted();
|
|
178
|
+
const page = this.#page;
|
|
179
|
+
if (!page) {
|
|
180
|
+
throw new Error('sql/browserRunner: browser is not running');
|
|
181
|
+
}
|
|
182
|
+
this.#pageError = null;
|
|
183
|
+
await page.goto(this.#harnessUrl, {waitUntil: 'load'});
|
|
184
|
+
// `__dfkReady` resolves once the engine is up and the preloads have loaded;
|
|
185
|
+
// Playwright awaits the promise and throws if it rejects.
|
|
186
|
+
try {
|
|
187
|
+
await page.evaluate(() => (window as unknown as {__dfkReady: Promise<void>}).__dfkReady);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.#pageError = messageOf(error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Runs one block; a SQL failure is thrown so the caller can weigh it. */
|
|
194
|
+
async run(sql: string): Promise<RunResult> {
|
|
195
|
+
if (this.#pageError) {
|
|
196
|
+
throw new Error(this.#pageError);
|
|
197
|
+
}
|
|
198
|
+
const page = this.#page;
|
|
199
|
+
if (!page) {
|
|
200
|
+
throw new Error('sql/browserRunner: no page is open — call newPage() first');
|
|
201
|
+
}
|
|
202
|
+
// `sql` crosses as an evaluate argument, never spliced into page code.
|
|
203
|
+
const result = await page.evaluate(
|
|
204
|
+
(statement) => (window as unknown as {__dfkRun: (s: string) => Promise<HarnessResult>}).__dfkRun(statement),
|
|
205
|
+
sql,
|
|
206
|
+
);
|
|
207
|
+
if (result?.error) {
|
|
208
|
+
throw new Error(result.error);
|
|
209
|
+
}
|
|
210
|
+
return {rows: result.rows, columns: result.columns};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Runs one statement and returns its actual rows (not just a count). Used by
|
|
215
|
+
* the vfs browser probe to read each value; `run()` is the verifier's path.
|
|
216
|
+
* Unlike `run()`, a failure is returned as `{error}` rather than thrown.
|
|
217
|
+
*/
|
|
218
|
+
async query(sql: string): Promise<QueryResult> {
|
|
219
|
+
if (this.#pageError) {
|
|
220
|
+
return {columns: [], rows: [], error: this.#pageError};
|
|
221
|
+
}
|
|
222
|
+
const page = this.#page;
|
|
223
|
+
if (!page) {
|
|
224
|
+
throw new Error('sql/browserRunner: no page is open — call newPage() first');
|
|
225
|
+
}
|
|
226
|
+
const result = await page.evaluate(
|
|
227
|
+
(statement) =>
|
|
228
|
+
(window as unknown as {__dfkQuery: (s: string) => Promise<QueryResult>}).__dfkQuery(statement),
|
|
229
|
+
sql,
|
|
230
|
+
);
|
|
231
|
+
return result ?? {columns: [], rows: [], error: 'no result'};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async close(): Promise<void> {
|
|
235
|
+
try {
|
|
236
|
+
await this.#page?.close();
|
|
237
|
+
} catch {
|
|
238
|
+
// The page may already be gone when the browser is shutting down.
|
|
239
|
+
}
|
|
240
|
+
this.#page = null;
|
|
241
|
+
try {
|
|
242
|
+
await this.#browser?.close();
|
|
243
|
+
} catch {
|
|
244
|
+
// Likewise the browser: closing twice or after a crash is not an error.
|
|
245
|
+
}
|
|
246
|
+
this.#browser = null;
|
|
247
|
+
if (this.#server) {
|
|
248
|
+
await new Promise<void>((resolve) => this.#server?.close(() => resolve()));
|
|
249
|
+
this.#server = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A path or `file:` URL to an absolute local path; leaves a remote URL alone. */
|
|
255
|
+
function resolveLocal(value: string): string {
|
|
256
|
+
return /^file:\/\//i.test(value) ? new URL(value).pathname.replace(/^\/(\w:)/i, '$1') : value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Static server
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
interface Routes {
|
|
264
|
+
harnessScript: string;
|
|
265
|
+
engine: {file: string; name: string};
|
|
266
|
+
worker: {file: string; name: string};
|
|
267
|
+
extension: {remoteUrl: string} | {localFile: string; baseName: string};
|
|
268
|
+
allowUnsigned: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A loopback static server for the harness: `harness.html` (generated, wiring
|
|
273
|
+
* the engine/extension URLs into the two contracts `harness.ts` reads),
|
|
274
|
+
* `harness.js` (the bundled harness), `/vendor/*` (engine + worker), and
|
|
275
|
+
* `/ext/*` (the served extension file). Nothing else is reachable; this is a
|
|
276
|
+
* short-lived test fixture, not a web server.
|
|
277
|
+
*/
|
|
278
|
+
async function startStaticServer(routes: Routes): Promise<Server> {
|
|
279
|
+
const harness = readFileSync(routes.harnessScript);
|
|
280
|
+
const engine = readFileSync(routes.engine.file);
|
|
281
|
+
const worker = readFileSync(routes.worker.file);
|
|
282
|
+
const extension =
|
|
283
|
+
'localFile' in routes.extension ? readFileSync(routes.extension.localFile) : null;
|
|
284
|
+
const extBaseName = 'baseName' in routes.extension ? routes.extension.baseName : '';
|
|
285
|
+
|
|
286
|
+
const extensionUrl =
|
|
287
|
+
'remoteUrl' in routes.extension
|
|
288
|
+
? routes.extension.remoteUrl
|
|
289
|
+
: `ext/${routes.extension.baseName}`;
|
|
290
|
+
|
|
291
|
+
const html = `<!doctype html>
|
|
292
|
+
<html lang="en">
|
|
293
|
+
<head>
|
|
294
|
+
<meta charset="utf-8" />
|
|
295
|
+
<title>duckfn sql harness</title>
|
|
296
|
+
<script>
|
|
297
|
+
window.DFK_HARNESS_BUNDLE = {
|
|
298
|
+
mainModule: '/vendor/${routes.engine.name}',
|
|
299
|
+
mainWorker: '/vendor/${routes.worker.name}',
|
|
300
|
+
};
|
|
301
|
+
</script>
|
|
302
|
+
<script id="dfk-sql-runtime" type="application/json">
|
|
303
|
+
{"allowUnsignedExtensions":${routes.allowUnsigned},"preload":[{"url":"${extensionUrl}"}]}
|
|
304
|
+
</script>
|
|
305
|
+
</head>
|
|
306
|
+
<body>
|
|
307
|
+
<script type="module" src="/harness.js"></script>
|
|
308
|
+
</body>
|
|
309
|
+
</html>`;
|
|
310
|
+
|
|
311
|
+
const server = createServer((request, response) => {
|
|
312
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
313
|
+
if (path === '/harness.html') {
|
|
314
|
+
send(response, 'text/html; charset=utf-8', Buffer.from(html, 'utf8'));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (path === '/harness.js') {
|
|
318
|
+
send(response, 'text/javascript; charset=utf-8', harness);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (path === `/vendor/${routes.engine.name}`) {
|
|
322
|
+
send(response, 'application/wasm', engine);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (path === `/vendor/${routes.worker.name}`) {
|
|
326
|
+
send(response, 'text/javascript; charset=utf-8', worker);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (extension && path === `/ext/${extBaseName}`) {
|
|
330
|
+
send(response, 'application/octet-stream', extension);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
response.writeHead(404, {'content-type': 'text/plain'});
|
|
334
|
+
response.end('not found');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await new Promise<void>((resolve, reject) => {
|
|
338
|
+
server.once('error', reject);
|
|
339
|
+
// Port 0 lets the OS pick a free one; a browser has no reason for a fixed
|
|
340
|
+
// port (unlike the Node worker's staging path, which embedded it).
|
|
341
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
342
|
+
});
|
|
343
|
+
return server;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function send(response: import('node:http').ServerResponse, type: string, body: Buffer): void {
|
|
347
|
+
response.writeHead(200, {'content-type': type, 'content-length': body.length});
|
|
348
|
+
response.end(body);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Browser detection
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/** A short list of well-known Chrome/Edge locations, overridable by DFK_BROWSER. */
|
|
356
|
+
function findBrowser(explicit?: string): string {
|
|
357
|
+
const candidate = explicit ?? process.env.DFK_BROWSER;
|
|
358
|
+
if (candidate) {
|
|
359
|
+
return candidate;
|
|
360
|
+
}
|
|
361
|
+
const paths: string[] = [];
|
|
362
|
+
if (process.platform === 'win32') {
|
|
363
|
+
const pf = process.env['PROGRAMFILES'] ?? 'C:\\Program Files';
|
|
364
|
+
const pf86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)';
|
|
365
|
+
const local = process.env['LOCALAPPDATA'] ?? '';
|
|
366
|
+
paths.push(
|
|
367
|
+
join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
368
|
+
join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
369
|
+
join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
370
|
+
join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
371
|
+
local ? join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') : '',
|
|
372
|
+
);
|
|
373
|
+
} else if (process.platform === 'darwin') {
|
|
374
|
+
paths.push(
|
|
375
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
376
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
paths.push(
|
|
380
|
+
'/usr/bin/google-chrome',
|
|
381
|
+
'/usr/bin/google-chrome-stable',
|
|
382
|
+
'/usr/bin/microsoft-edge',
|
|
383
|
+
'/usr/bin/chromium',
|
|
384
|
+
'/usr/bin/chromium-browser',
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
for (const path of paths) {
|
|
388
|
+
if (path && existsSync(path)) {
|
|
389
|
+
return path;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
throw new Error(
|
|
393
|
+
'sql/browserRunner: no Chrome/Edge found — pass --browser <path> or set DFK_BROWSER',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function messageOf(error: unknown): string {
|
|
398
|
+
if (error instanceof Error) {
|
|
399
|
+
return error.message;
|
|
400
|
+
}
|
|
401
|
+
return String(error);
|
|
402
|
+
}
|