duckfn-docs-kit 0.1.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 +689 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/dom.d.ts +69 -0
- package/dist/home/DfkFeatures.d.ts +20 -0
- package/dist/home/DfkHero.d.ts +25 -0
- package/dist/home/DfkNextSteps.d.ts +16 -0
- package/dist/home/styles.d.ts +8 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +2 -0
- package/dist/register-DKLiYs-F.js +2324 -0
- package/dist/register.d.ts +10 -0
- package/dist/remark.d.ts +21 -0
- package/dist/remark.js +15 -0
- package/dist/runtimeConfig-Bokbb8VH.js +106 -0
- package/dist/sql/DfkSql.d.ts +7 -0
- package/dist/sql/PreviewTabs.d.ts +37 -0
- package/dist/sql/client.d.ts +1 -0
- package/dist/sql/client.js +4 -0
- package/dist/sql/editor.d.ts +16 -0
- package/dist/sql/extensions.d.ts +108 -0
- package/dist/sql/extensions.js +198 -0
- package/dist/sql/remark.d.ts +88 -0
- package/dist/sql/remark.js +69 -0
- package/dist/sql/renderers.d.ts +44 -0
- package/dist/sql/runtime.d.ts +105 -0
- package/dist/sql/runtimeConfig.d.ts +80 -0
- package/dist/sql/styles.d.ts +6 -0
- package/dist/toc-toggle/TocToggle.d.ts +46 -0
- package/dist/toc-toggle/TocToggle.js +69 -0
- package/dist/toc-toggle/client.d.ts +1 -0
- package/dist/toc-toggle/client.js +9 -0
- package/dist/toc-toggle/plugin.d.ts +36 -0
- package/dist/toc-toggle/plugin.js +13 -0
- package/dist/types.d.ts +42 -0
- package/package.json +73 -0
- package/src/dom.ts +109 -0
- package/src/home/DfkFeatures.ts +78 -0
- package/src/home/DfkHero.ts +128 -0
- package/src/home/DfkNextSteps.ts +73 -0
- package/src/home/home.css +520 -0
- package/src/home/styles.ts +28 -0
- package/src/index.ts +59 -0
- package/src/kit.css +19 -0
- package/src/register.ts +39 -0
- package/src/remark.ts +60 -0
- package/src/sql/DfkSql.css +226 -0
- package/src/sql/DfkSql.ts +620 -0
- package/src/sql/PreviewTabs.ts +169 -0
- package/src/sql/client.ts +16 -0
- package/src/sql/editor.ts +75 -0
- package/src/sql/extensions.ts +470 -0
- package/src/sql/remark.ts +213 -0
- package/src/sql/renderers.ts +916 -0
- package/src/sql/runtime.ts +348 -0
- package/src/sql/runtimeConfig.ts +249 -0
- package/src/sql/sql.css +397 -0
- package/src/sql/styles.ts +24 -0
- package/src/theme/tokens.css +75 -0
- package/src/toc-toggle/TocToggle.css +69 -0
- package/src/toc-toggle/TocToggle.ts +172 -0
- package/src/toc-toggle/client.ts +20 -0
- package/src/toc-toggle/plugin.ts +54 -0
- package/src/types.ts +47 -0
- package/src/vite-env.d.ts +8 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type * as DuckdbWasm from '@duckdb/duckdb-wasm';
|
|
2
|
+
import {
|
|
3
|
+
DFK_SQL_RUNTIME_TAG_ID,
|
|
4
|
+
EXTENSION_NAME_PATTERN,
|
|
5
|
+
REPOSITORY_KEYWORDS,
|
|
6
|
+
REPOSITORY_URL_PATTERN,
|
|
7
|
+
extensionBaseName,
|
|
8
|
+
isAbsoluteHttpUrl,
|
|
9
|
+
normalizePreloadEntry,
|
|
10
|
+
parseSiteRuntimeConfig,
|
|
11
|
+
type PreloadEntry,
|
|
12
|
+
type SiteRuntimeConfig,
|
|
13
|
+
} from './runtimeConfig';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The browser-side DuckDB-Wasm runtime: one instance per docs-site frontend
|
|
17
|
+
* runtime (module-level singleton), *not* persisted across page loads.
|
|
18
|
+
*
|
|
19
|
+
* Design points:
|
|
20
|
+
*
|
|
21
|
+
* - `@duckdb/duckdb-wasm` is only ever reached through a dynamic `import()`,
|
|
22
|
+
* so Docusaurus' Node prerender pass and the initial page load never touch
|
|
23
|
+
* it. The wasm binary and the worker script come from the official jsDelivr
|
|
24
|
+
* CDN (`getJsDelivrBundles` + `selectBundle`), which sidesteps any webpack
|
|
25
|
+
* `asyncWebAssembly` / worker configuration on the consuming site.
|
|
26
|
+
* `selectBundle` falls back to a non-`SharedArrayBuffer` bundle when the
|
|
27
|
+
* page is not crossOrigin-isolated (GitHub Pages), so it works everywhere.
|
|
28
|
+
* - `init()` is idempotent and retryable: a failed init leaves `state` at
|
|
29
|
+
* `'error'` and clears the memoised promise, so a later Run click can try
|
|
30
|
+
* again.
|
|
31
|
+
* - `execute()` hands the whole string to DuckDB. Multi-statement queries
|
|
32
|
+
* return the result of the **last** statement, which is exactly the
|
|
33
|
+
* documented behaviour for runnable blocks.
|
|
34
|
+
* - Extensions get into the shared instance two ways, both through the same
|
|
35
|
+
* memoised loader: the **site preload list** (the ordered `preload` array of
|
|
36
|
+
* the JSON `<script>` tag the build-time plugin injects, loaded right after
|
|
37
|
+
* `connect()` so a block can rely on the extension without naming it), and
|
|
38
|
+
* the **per-block `extensions` config** loaded on demand by
|
|
39
|
+
* {@link DuckDBRuntime.loadExtension}. Note that on WebAssembly `INSTALL` is
|
|
40
|
+
* a no-op (there is no persistent storage to install *into*): it only
|
|
41
|
+
* records where a later `LOAD` fetches a name from. A load by name fetches
|
|
42
|
+
* `<repository>/duckdb-wasm/<revision>/<platform>/<name>.duckdb_extension.wasm`
|
|
43
|
+
* and verifies the signature; a `{url}` preload fetches exactly that URL
|
|
44
|
+
* (which is why the URL must be absolute — the worker runs from a blob URL
|
|
45
|
+
* and cannot resolve relative paths). Either way, the text before the first
|
|
46
|
+
* dot of the file name is the entry symbol DuckDB looks up, so a release
|
|
47
|
+
* asset like `duckfn-wasm_eh.duckdb_extension.wasm` has to be renamed to
|
|
48
|
+
* `duckfn.duckdb_extension.wasm` on the way in (enforced by validation).
|
|
49
|
+
* - Extension names, repositories and URLs are **validated, not escaped** (see
|
|
50
|
+
* `sql/runtimeConfig`): `LOAD` cannot take them as parameters.
|
|
51
|
+
* - `allowUnsignedExtensions` is opt-in and per-instance: it is a database
|
|
52
|
+
* setting fixed by `open()`, so it has to be known before the first
|
|
53
|
+
* `connect()`. The site-wide value (from the injected config) and the first
|
|
54
|
+
* caller's are merged by whichever `init()` actually creates the instance.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export type RuntimeState = 'idle' | 'loading' | 'ready' | 'error';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Options for {@link DuckDBRuntime.init}. They are merged with the site-wide
|
|
61
|
+
* injected config, and only read by the caller that actually creates the
|
|
62
|
+
* instance: `allowUnsignedExtensions` is fixed at `open()` time and later
|
|
63
|
+
* callers cannot retune a database that already exists.
|
|
64
|
+
*/
|
|
65
|
+
export interface RuntimeOptions {
|
|
66
|
+
/** Let `LOAD` accept an extension whose signature does not verify. */
|
|
67
|
+
allowUnsignedExtensions?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Options for {@link DuckDBRuntime.loadExtension}. */
|
|
71
|
+
export interface LoadExtensionOptions {
|
|
72
|
+
/** `community`, `core` or a repository URL, instead of the official default. */
|
|
73
|
+
repository?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A normalised query result: column names plus row objects keyed by them. */
|
|
77
|
+
export interface QueryResult {
|
|
78
|
+
columns: string[];
|
|
79
|
+
rows: Record<string, unknown>[];
|
|
80
|
+
/** Set when the statement failed; `columns`/`rows` are then empty. */
|
|
81
|
+
error?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let instance: DuckDBRuntime | null = null;
|
|
85
|
+
|
|
86
|
+
export class DuckDBRuntime {
|
|
87
|
+
/** The per-frontend-runtime singleton; shared by every `<dfk-sql>` block. */
|
|
88
|
+
static getInstance(): DuckDBRuntime {
|
|
89
|
+
instance ??= new DuckDBRuntime();
|
|
90
|
+
return instance;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#state: RuntimeState = 'idle';
|
|
94
|
+
#message = '';
|
|
95
|
+
#init: Promise<void> | null = null;
|
|
96
|
+
#db: DuckdbWasm.AsyncDuckDB | null = null;
|
|
97
|
+
#conn: DuckdbWasm.AsyncDuckDBConnection | null = null;
|
|
98
|
+
#allowUnsigned = false;
|
|
99
|
+
/** The injected site config; read (and validated) once on first init. */
|
|
100
|
+
#site: SiteRuntimeConfig | null = null;
|
|
101
|
+
/** Loaded / in-flight extensions, keyed by repository + name, or by URL. */
|
|
102
|
+
#loads = new Map<string, Promise<void>>();
|
|
103
|
+
|
|
104
|
+
get state(): RuntimeState {
|
|
105
|
+
return this.#state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The last init failure's message, for the UI to display. */
|
|
109
|
+
get message(): string {
|
|
110
|
+
return this.#message;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates the database in the background (first Run click triggers it; a
|
|
115
|
+
* consuming site may also call it early to warm the instance). Concurrent
|
|
116
|
+
* callers share one promise, and it only resolves once the site's preloads
|
|
117
|
+
* are loaded too.
|
|
118
|
+
*
|
|
119
|
+
* Options and the injected site config are only read by the caller that
|
|
120
|
+
* actually creates the instance: `allowUnsignedExtensions` is fixed at
|
|
121
|
+
* `open()` time, and later callers cannot retune a database that already
|
|
122
|
+
* exists. A malformed injected config fails here, before any download.
|
|
123
|
+
*/
|
|
124
|
+
init(options: RuntimeOptions = {}): Promise<void> {
|
|
125
|
+
if (options.allowUnsignedExtensions) {
|
|
126
|
+
this.#allowUnsigned = true;
|
|
127
|
+
}
|
|
128
|
+
if (this.#state === 'ready') {
|
|
129
|
+
return Promise.resolve();
|
|
130
|
+
}
|
|
131
|
+
if (this.#init) {
|
|
132
|
+
return this.#init;
|
|
133
|
+
}
|
|
134
|
+
const site = this.#siteConfig();
|
|
135
|
+
if (site.allowUnsignedExtensions) {
|
|
136
|
+
this.#allowUnsigned = true;
|
|
137
|
+
}
|
|
138
|
+
this.#state = 'loading';
|
|
139
|
+
this.#init = this.#create(site.preload).catch((error: unknown) => {
|
|
140
|
+
this.#state = 'error';
|
|
141
|
+
this.#message = errorMessage(error);
|
|
142
|
+
// Drop the memoised promise so the next click retries from scratch.
|
|
143
|
+
this.#init = null;
|
|
144
|
+
throw error;
|
|
145
|
+
});
|
|
146
|
+
return this.#init;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The injected config, validated once; a missing tag means "no preloads". */
|
|
150
|
+
#siteConfig(): SiteRuntimeConfig {
|
|
151
|
+
this.#site ??= readSiteRuntimeConfig();
|
|
152
|
+
return this.#site;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async #create(preload: readonly PreloadEntry[]): Promise<void> {
|
|
156
|
+
const duckdb = await import('@duckdb/duckdb-wasm');
|
|
157
|
+
const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
|
|
158
|
+
if (!bundle.mainWorker) {
|
|
159
|
+
throw new Error('The selected DuckDB-Wasm bundle has no worker script');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Wrap the worker script in a same-origin Blob URL: the site itself is
|
|
163
|
+
// not COOP/COEP-isolated, and this keeps the CDN script same-origin-safe
|
|
164
|
+
// regardless of CORS headers.
|
|
165
|
+
const workerUrl = URL.createObjectURL(
|
|
166
|
+
new Blob([`importScripts("${bundle.mainWorker}");`], {
|
|
167
|
+
type: 'text/javascript',
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
const worker = new Worker(workerUrl);
|
|
171
|
+
URL.revokeObjectURL(workerUrl);
|
|
172
|
+
const logger = new duckdb.ConsoleLogger();
|
|
173
|
+
this.#db = new duckdb.AsyncDuckDB(logger, worker);
|
|
174
|
+
await this.#db.instantiate(bundle.mainModule, bundle.pthreadWorker);
|
|
175
|
+
// `open()` is where database-level settings land, and it has to run before
|
|
176
|
+
// the first `connect()`. It is called unconditionally so the instance's
|
|
177
|
+
// configuration has exactly one source of truth.
|
|
178
|
+
await this.#db.open({allowUnsignedExtensions: this.#allowUnsigned});
|
|
179
|
+
this.#conn = await this.#db.connect();
|
|
180
|
+
// Site preloads run before `ready`: every block may rely on them, and the
|
|
181
|
+
// first Run click pays for all of them at once. Sequential on purpose —
|
|
182
|
+
// the list is ordered (one extension may build on another) and parallel
|
|
183
|
+
// loads would race the shared connection.
|
|
184
|
+
for (const entry of preload) {
|
|
185
|
+
await this.#loadEntry(entry);
|
|
186
|
+
}
|
|
187
|
+
this.#state = 'ready';
|
|
188
|
+
this.#message = '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Runs `sql` and resolves with the (last statement's) result. */
|
|
192
|
+
async execute(sql: string): Promise<QueryResult> {
|
|
193
|
+
await this.init();
|
|
194
|
+
const conn = this.#conn;
|
|
195
|
+
if (!conn) {
|
|
196
|
+
return {columns: [], rows: [], error: this.#message || 'DuckDB unavailable'};
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const table = await conn.query(sql);
|
|
200
|
+
return {
|
|
201
|
+
columns: table.schema.fields.map((field) => field.name),
|
|
202
|
+
rows: table.toArray() as Record<string, unknown>[],
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return {columns: [], rows: [], error: errorMessage(error)};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Loads one extension on demand — what a runnable block's `extensions`
|
|
211
|
+
* config ends up doing.
|
|
212
|
+
*
|
|
213
|
+
* `LOAD` is the whole mechanism on WebAssembly: it fetches the extension's
|
|
214
|
+
* `.duckdb_extension.wasm` and verifies the signature before loading it.
|
|
215
|
+
* `INSTALL … FROM` only records *where* a later `LOAD` should fetch from
|
|
216
|
+
* (there is no persistent storage to install into), which is also why it is
|
|
217
|
+
* used for a non-default `repository` instead of the global
|
|
218
|
+
* `SET custom_extension_repository` — the recorded source stays attached to
|
|
219
|
+
* this one extension.
|
|
220
|
+
*
|
|
221
|
+
* The name and repository are validated rather than escaped — `LOAD` takes
|
|
222
|
+
* an identifier, not a parameter, so anything that could terminate the
|
|
223
|
+
* statement is rejected outright. Successful loads (and in-flight ones) are
|
|
224
|
+
* memoised per repository + name; a **failure** is not, so a Run click can
|
|
225
|
+
* retry.
|
|
226
|
+
*/
|
|
227
|
+
async loadExtension(name: string, options: LoadExtensionOptions = {}): Promise<void> {
|
|
228
|
+
await this.init();
|
|
229
|
+
return this.#loadEntry({name, repository: options.repository});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The shared loader behind site preloads and {@link loadExtension}:
|
|
234
|
+
* validates and normalises the entry, then performs it at most once. It
|
|
235
|
+
* never calls `init()` itself — preloads run from inside `#create()`, and
|
|
236
|
+
* awaiting `init()` there would deadlock on its own promise.
|
|
237
|
+
*/
|
|
238
|
+
#loadEntry(entry: unknown): Promise<void> {
|
|
239
|
+
const normalized = normalizePreloadEntry(entry);
|
|
240
|
+
if (typeof normalized === 'string') {
|
|
241
|
+
return this.#memo(`\u0000${normalized}`, () => this.#loadByName(normalized));
|
|
242
|
+
}
|
|
243
|
+
if ('name' in normalized) {
|
|
244
|
+
const {name, repository} = normalized;
|
|
245
|
+
return this.#memo(`${repository ?? ''}\u0000${name}`, () =>
|
|
246
|
+
this.#loadByName(name, repository),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const {url} = normalized;
|
|
250
|
+
return this.#memo(`url\u0000${url}`, () => this.#loadFromUrl(url));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Memoises a load per key; a failure drops the entry so a retry can run. */
|
|
254
|
+
#memo(key: string, load: () => Promise<void>): Promise<void> {
|
|
255
|
+
const memoised = this.#loads.get(key);
|
|
256
|
+
if (memoised) {
|
|
257
|
+
return memoised;
|
|
258
|
+
}
|
|
259
|
+
const loading = load().catch((error: unknown) => {
|
|
260
|
+
this.#loads.delete(key);
|
|
261
|
+
throw error;
|
|
262
|
+
});
|
|
263
|
+
this.#loads.set(key, loading);
|
|
264
|
+
return loading;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async #loadByName(name: string, repository?: string): Promise<void> {
|
|
268
|
+
if (!EXTENSION_NAME_PATTERN.test(name)) {
|
|
269
|
+
throw new Error(`Not a valid extension name: ${name}`);
|
|
270
|
+
}
|
|
271
|
+
const conn = this.#requireConnection();
|
|
272
|
+
if (repository !== undefined) {
|
|
273
|
+
await conn.query(`INSTALL ${name} FROM ${repositoryClause(repository)}`);
|
|
274
|
+
}
|
|
275
|
+
await conn.query(`LOAD ${name}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Loads an extension file from the absolute URL a `{url}` entry names. */
|
|
279
|
+
async #loadFromUrl(url: string): Promise<void> {
|
|
280
|
+
const conn = this.#requireConnection();
|
|
281
|
+
await conn.query(`LOAD '${resolveExtensionUrl(url)}'`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#requireConnection(): DuckdbWasm.AsyncDuckDBConnection {
|
|
285
|
+
const conn = this.#conn;
|
|
286
|
+
if (!conn) {
|
|
287
|
+
throw new Error(this.#message || 'DuckDB unavailable');
|
|
288
|
+
}
|
|
289
|
+
return conn;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The `FROM` clause of `INSTALL`: bare keywords stay bare, URLs are quoted so
|
|
295
|
+
* the recorded repository is exactly the given one; the URL pattern forbids
|
|
296
|
+
* quote characters from reaching the SQL literal.
|
|
297
|
+
*/
|
|
298
|
+
function repositoryClause(repository: string): string {
|
|
299
|
+
const keyword = repository.toLowerCase();
|
|
300
|
+
if (REPOSITORY_KEYWORDS.has(keyword)) {
|
|
301
|
+
return keyword;
|
|
302
|
+
}
|
|
303
|
+
if (!REPOSITORY_URL_PATTERN.test(repository)) {
|
|
304
|
+
throw new Error(`Not a valid extension repository: ${repository}`);
|
|
305
|
+
}
|
|
306
|
+
return `'${repository}'`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Turns a preload `url` into the absolute URL the worker will fetch: the
|
|
311
|
+
* worker runs from a blob URL and cannot resolve relative paths, so
|
|
312
|
+
* site-relative entries (already prefixed with the site's baseUrl by the
|
|
313
|
+
* build-time plugin) are resolved against the page origin here, on the main
|
|
314
|
+
* thread.
|
|
315
|
+
*/
|
|
316
|
+
function resolveExtensionUrl(url: string): string {
|
|
317
|
+
const absolute = isAbsoluteHttpUrl(url) ? url : new URL(url, window.location.origin).href;
|
|
318
|
+
if (!EXTENSION_NAME_PATTERN.test(extensionBaseName(absolute))) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`An extension file must be named <extension>.duckdb_extension.wasm — the base name ` +
|
|
321
|
+
`before the first dot is the entry symbol: ${absolute}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return absolute;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Reads the JSON config the build-time plugin injects; missing means defaults. */
|
|
328
|
+
function readSiteRuntimeConfig(): SiteRuntimeConfig {
|
|
329
|
+
if (typeof document === 'undefined') {
|
|
330
|
+
return {preload: []};
|
|
331
|
+
}
|
|
332
|
+
const text = document.getElementById(DFK_SQL_RUNTIME_TAG_ID)?.textContent?.trim();
|
|
333
|
+
if (!text) {
|
|
334
|
+
return {preload: []};
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
return parseSiteRuntimeConfig(JSON.parse(text));
|
|
338
|
+
} catch (error: unknown) {
|
|
339
|
+
throw new Error(`Invalid <script id="${DFK_SQL_RUNTIME_TAG_ID}"> config: ${errorMessage(error)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function errorMessage(error: unknown): string {
|
|
344
|
+
if (error instanceof Error) {
|
|
345
|
+
return error.message;
|
|
346
|
+
}
|
|
347
|
+
return String(error);
|
|
348
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between the build-time plugin (`sql/extensions`) and the
|
|
3
|
+
* browser runtime (`sql/runtime`).
|
|
4
|
+
*
|
|
5
|
+
* The plugin writes one JSON `<script>` tag into every page carrying the
|
|
6
|
+
* site's runtime configuration; the runtime reads it once, when the shared
|
|
7
|
+
* DuckDB instance is first created. Tag id, entry shapes and the validators
|
|
8
|
+
* live together in this dependency-free module so the two sides cannot drift —
|
|
9
|
+
* and so the browser bundle never drags in Node code (or the Node plugin the
|
|
10
|
+
* DOM).
|
|
11
|
+
*
|
|
12
|
+
* This module must not import anything: it is bundled into both the browser
|
|
13
|
+
* and the Node entry points.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Id of the JSON config `<script>` the build-time plugin injects per page. */
|
|
17
|
+
export const DFK_SQL_RUNTIME_TAG_ID = 'dfk-sql-runtime';
|
|
18
|
+
|
|
19
|
+
/** A bare SQL identifier: `LOAD` / `INSTALL` cannot be parameterised, so this is the guard. */
|
|
20
|
+
export const EXTENSION_NAME_PATTERN = /^[a-z][a-z0-9_]*$/i;
|
|
21
|
+
|
|
22
|
+
/** A repository URL with no character that could escape the SQL string literal. */
|
|
23
|
+
export const REPOSITORY_URL_PATTERN = /^https?:\/\/[^\s'";`<>\\]+$/i;
|
|
24
|
+
|
|
25
|
+
/** Bare `FROM` keywords `INSTALL` accepts instead of a repository URL. */
|
|
26
|
+
export const REPOSITORY_KEYWORDS = new Set(['community', 'core']);
|
|
27
|
+
|
|
28
|
+
/** An extension preloaded by name, from the official repository or another one. */
|
|
29
|
+
export interface NamedPreloadEntry {
|
|
30
|
+
/** The extension name, e.g. `duckfn`. */
|
|
31
|
+
name: string;
|
|
32
|
+
/** `community`, `core` or a repository URL; omitted = the official repository. */
|
|
33
|
+
repository?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A GitHub release asset the build-time plugin copies to `url` at build time. */
|
|
37
|
+
export interface PreloadReleaseSource {
|
|
38
|
+
/** The GitHub repository, `owner/name`. */
|
|
39
|
+
repository: string;
|
|
40
|
+
/** The asset name on that repository's latest release, e.g. `duckfn-wasm_eh.duckdb_extension.wasm`. */
|
|
41
|
+
asset: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An extension preloaded from a file, served by the site itself or remotely. */
|
|
45
|
+
export interface UrlPreloadEntry {
|
|
46
|
+
/**
|
|
47
|
+
* A site-relative path (`duckdb-extensions/duckfn.duckdb_extension.wasm`),
|
|
48
|
+
* resolved against the site's baseUrl and served from `static/`; or an
|
|
49
|
+
* absolute `http(s)://` URL.
|
|
50
|
+
*
|
|
51
|
+
* The base name of the last path segment — the text before its first dot —
|
|
52
|
+
* must be the extension name: on WebAssembly that text is what DuckDB turns
|
|
53
|
+
* into the `<name>_init_c_api` entry symbol, which is exactly why a release
|
|
54
|
+
* asset like `duckfn-wasm_eh.duckdb_extension.wasm` has to be renamed to
|
|
55
|
+
* `duckfn.duckdb_extension.wasm` on the way in.
|
|
56
|
+
*/
|
|
57
|
+
url: string;
|
|
58
|
+
/** Fetch the file from the repository's latest GitHub release at build time. */
|
|
59
|
+
release?: PreloadReleaseSource;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One entry of the ordered site-level preload list. */
|
|
63
|
+
export type PreloadEntry = string | NamedPreloadEntry | UrlPreloadEntry;
|
|
64
|
+
|
|
65
|
+
/** The config the build-time plugin injects and the browser runtime reads. */
|
|
66
|
+
export interface SiteRuntimeConfig {
|
|
67
|
+
/** Let `LOAD` accept extensions without a valid signature; a site-wide opt-in. */
|
|
68
|
+
allowUnsignedExtensions?: boolean;
|
|
69
|
+
/** Ordered: every entry loads, one after another, before the instance is `ready`. */
|
|
70
|
+
preload: PreloadEntry[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `owner/name`. */
|
|
74
|
+
const GITHUB_REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/;
|
|
75
|
+
|
|
76
|
+
/** A file name / URL safe for the injected JSON, the SQL literal and a static path. */
|
|
77
|
+
const SAFE_URL_TEXT_PATTERN = /^[^\s'";`<>\\?#]+$/;
|
|
78
|
+
|
|
79
|
+
/** Anything that starts like a URL scheme; only `http(s):` may follow it. */
|
|
80
|
+
const URL_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
|
|
81
|
+
|
|
82
|
+
/** True for the only absolute URL form a preload may carry. */
|
|
83
|
+
export function isAbsoluteHttpUrl(value: string): boolean {
|
|
84
|
+
return /^https?:\/\//i.test(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The entry-symbol base name of a served extension file: the text before the
|
|
89
|
+
* first dot in its last path segment. DuckDB loads a direct file by that name
|
|
90
|
+
* (`duckfn.duckdb_extension.wasm` -> `duckfn` -> `duckfn_init_c_api`), so a
|
|
91
|
+
* file still carrying a platform suffix cannot be loaded as-is.
|
|
92
|
+
*/
|
|
93
|
+
export function extensionBaseName(url: string): string {
|
|
94
|
+
const withoutQuery = url.split(/[?#]/, 1)[0];
|
|
95
|
+
const segment = withoutQuery.slice(withoutQuery.lastIndexOf('/') + 1);
|
|
96
|
+
const dot = segment.indexOf('.');
|
|
97
|
+
return dot === -1 ? segment : segment.slice(0, dot);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validates and normalises one preload entry, dropping unknown keys and
|
|
102
|
+
* lower-casing repository keywords; throws with a readable message on
|
|
103
|
+
* anything that could not be turned into a safe `LOAD` / `INSTALL` statement
|
|
104
|
+
* or a static file path.
|
|
105
|
+
*/
|
|
106
|
+
export function normalizePreloadEntry(entry: unknown): PreloadEntry {
|
|
107
|
+
if (typeof entry === 'string') {
|
|
108
|
+
return requireExtensionName(entry, 'A preload entry');
|
|
109
|
+
}
|
|
110
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
111
|
+
throw new Error(`A preload entry must be a string, {name, repository?} or {url, release?}: ${describe(entry)}`);
|
|
112
|
+
}
|
|
113
|
+
const record = entry as Record<string, unknown>;
|
|
114
|
+
const hasName = record.name !== undefined;
|
|
115
|
+
const hasUrl = record.url !== undefined;
|
|
116
|
+
if (hasName === hasUrl) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`A preload entry needs exactly one of \`name\` (load by name) or \`url\` (load a file): ${describe(entry)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return hasName ? normalizeNamedEntry(record) : normalizeUrlEntry(record);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Validates the injected config envelope and normalises every preload entry. */
|
|
125
|
+
export function parseSiteRuntimeConfig(value: unknown): SiteRuntimeConfig {
|
|
126
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
127
|
+
throw new Error(`Expected an object: ${describe(value)}`);
|
|
128
|
+
}
|
|
129
|
+
const record = value as Record<string, unknown>;
|
|
130
|
+
requireOnlyKeys(record, ['allowUnsignedExtensions', 'preload'], 'The config');
|
|
131
|
+
const {allowUnsignedExtensions} = record;
|
|
132
|
+
if (allowUnsignedExtensions !== undefined && typeof allowUnsignedExtensions !== 'boolean') {
|
|
133
|
+
throw new Error(`\`allowUnsignedExtensions\` must be a boolean: ${describe(allowUnsignedExtensions)}`);
|
|
134
|
+
}
|
|
135
|
+
const raw = record.preload ?? [];
|
|
136
|
+
if (!Array.isArray(raw)) {
|
|
137
|
+
throw new Error(`\`preload\` must be an array: ${describe(raw)}`);
|
|
138
|
+
}
|
|
139
|
+
const preload = raw.map((entry, index) => {
|
|
140
|
+
try {
|
|
141
|
+
return normalizePreloadEntry(entry);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(`preload[${index}]: ${errorMessage(error)}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return allowUnsignedExtensions === undefined ? {preload} : {allowUnsignedExtensions, preload};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeNamedEntry(record: Record<string, unknown>): NamedPreloadEntry {
|
|
150
|
+
requireOnlyKeys(record, ['name', 'repository'], 'A name preload entry');
|
|
151
|
+
const name = requireExtensionName(record.name, '`name`');
|
|
152
|
+
const {repository} = record;
|
|
153
|
+
if (repository === undefined) {
|
|
154
|
+
return {name};
|
|
155
|
+
}
|
|
156
|
+
if (typeof repository === 'string') {
|
|
157
|
+
const keyword = repository.toLowerCase();
|
|
158
|
+
if (REPOSITORY_KEYWORDS.has(keyword)) {
|
|
159
|
+
return {name, repository: keyword};
|
|
160
|
+
}
|
|
161
|
+
if (REPOSITORY_URL_PATTERN.test(repository)) {
|
|
162
|
+
return {name, repository};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`\`repository\` must be 'community', 'core' or an http(s) URL without quotes: ${describe(repository)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeUrlEntry(record: Record<string, unknown>): UrlPreloadEntry {
|
|
169
|
+
requireOnlyKeys(record, ['url', 'release'], 'A url preload entry');
|
|
170
|
+
const rawUrl = record.url;
|
|
171
|
+
if (typeof rawUrl !== 'string' || !SAFE_URL_TEXT_PATTERN.test(rawUrl)) {
|
|
172
|
+
throw new Error(`\`url\` must be a file path or http(s) URL without quotes, whitespace or backslashes: ${describe(rawUrl)}`);
|
|
173
|
+
}
|
|
174
|
+
const absolute = isAbsoluteHttpUrl(rawUrl);
|
|
175
|
+
if (!absolute && URL_SCHEME_PATTERN.test(rawUrl)) {
|
|
176
|
+
throw new Error(`\`url\` must be a site-relative path or an http(s) URL: ${describe(rawUrl)}`);
|
|
177
|
+
}
|
|
178
|
+
const url = absolute ? rawUrl : normalizeSitePath(rawUrl);
|
|
179
|
+
if (!EXTENSION_NAME_PATTERN.test(extensionBaseName(url))) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`The last path segment of \`url\` must start with the extension name before its first dot ` +
|
|
182
|
+
`(that base names the entry symbol, e.g. duckfn.duckdb_extension.wasm): ${url}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const {release} = record;
|
|
186
|
+
if (release === undefined) {
|
|
187
|
+
return {url};
|
|
188
|
+
}
|
|
189
|
+
if (absolute) {
|
|
190
|
+
throw new Error(`\`release\` copies the asset into the site's static directory, so \`url\` has to be site-relative: ${url}`);
|
|
191
|
+
}
|
|
192
|
+
return {url, release: normalizeReleaseSource(release)};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Strips the leading slash and rejects anything that cannot join a baseUrl safely. */
|
|
196
|
+
function normalizeSitePath(value: string): string {
|
|
197
|
+
if (value.startsWith('//')) {
|
|
198
|
+
throw new Error(`Protocol-relative URLs are not supported: ${value}`);
|
|
199
|
+
}
|
|
200
|
+
const path = value.replace(/^\/+/, '');
|
|
201
|
+
const segments = path.split('/');
|
|
202
|
+
if (path === '' || segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
203
|
+
throw new Error(`\`url\` must be a plain site-relative file path: ${value}`);
|
|
204
|
+
}
|
|
205
|
+
return path;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeReleaseSource(value: unknown): PreloadReleaseSource {
|
|
209
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
210
|
+
throw new Error(`\`release\` must be {repository, asset}: ${describe(value)}`);
|
|
211
|
+
}
|
|
212
|
+
const record = value as Record<string, unknown>;
|
|
213
|
+
requireOnlyKeys(record, ['repository', 'asset'], '`release`');
|
|
214
|
+
const {repository, asset} = record;
|
|
215
|
+
if (typeof repository !== 'string' || !GITHUB_REPOSITORY_PATTERN.test(repository)) {
|
|
216
|
+
throw new Error(`\`release.repository\` must look like 'owner/name': ${describe(repository)}`);
|
|
217
|
+
}
|
|
218
|
+
if (typeof asset !== 'string' || !SAFE_URL_TEXT_PATTERN.test(asset) || asset.includes('/')) {
|
|
219
|
+
throw new Error(`\`release.asset\` is the release asset's file name (no slashes): ${describe(asset)}`);
|
|
220
|
+
}
|
|
221
|
+
return {repository, asset};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function requireExtensionName(value: unknown, label: string): string {
|
|
225
|
+
if (typeof value !== 'string' || !EXTENSION_NAME_PATTERN.test(value)) {
|
|
226
|
+
throw new Error(`${label} must be a bare SQL identifier ([a-z][a-z0-9_]*): ${describe(value)}`);
|
|
227
|
+
}
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function requireOnlyKeys(record: Record<string, unknown>, allowed: string[], label: string): void {
|
|
232
|
+
for (const key of Object.keys(record)) {
|
|
233
|
+
if (!allowed.includes(key)) {
|
|
234
|
+
throw new Error(`${label} has an unknown key \`${key}\` (expected: ${allowed.join(' / ')})`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function describe(value: unknown): string {
|
|
240
|
+
try {
|
|
241
|
+
return JSON.stringify(value) ?? String(value);
|
|
242
|
+
} catch {
|
|
243
|
+
return String(value);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function errorMessage(error: unknown): string {
|
|
248
|
+
return error instanceof Error ? error.message : String(error);
|
|
249
|
+
}
|