diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 00000000..d74ff0fb --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,93 @@ +# Copyright 2026 Columnar Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Wasm +on: + push: + branches: + - 'main' + paths: + - '**/*.go' + - 'auth/**' + - 'config/**' + - 'internal/**' + - 'wasm/**' + - 'packages/npm-wasm/**' + - 'cmd/dbc/testdata/**' + - 'go.mod' + - 'go.sum' + - '.github/workflows/wasm.yml' + pull_request: + paths: + - '**/*.go' + - 'auth/**' + - 'config/**' + - 'internal/**' + - 'wasm/**' + - 'packages/npm-wasm/**' + - 'cmd/dbc/testdata/**' + - 'go.mod' + - 'go.sum' + - '.github/workflows/wasm.yml' + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + build-and-smoke: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # windows-latest is intentionally excluded: the WASM Windows-host path is + # experimental and not yet passing (driver discovery under GOOS=js — see + # https://github.com/columnar-tech/dbc/issues/396). Re-add when fixed. + os: [ 'ubuntu-latest', 'macos-latest' ] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: 1.26 + cache: true + cache-dependency-path: go.sum + - name: Node version + run: node --version + - name: Build @columnar-tech/dbc-wasm + run: node packages/npm-wasm/scripts/build.js + - name: Node loader unit tests + run: node packages/npm-wasm/test/normalize.test.cjs + - name: Node smoke test + run: node packages/npm-wasm/test/smoke.cjs + - name: Node worker smoke test + run: node packages/npm-wasm/test/worker.test.cjs + - name: Setup Deno + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + - name: Deno version + run: deno --version + - name: Deno loader unit tests + run: deno run -A packages/npm-wasm/test/normalize.test.cjs + - name: Deno smoke test + run: deno run -A packages/npm-wasm/test/smoke.cjs + - name: Deno worker smoke test + run: deno run -A packages/npm-wasm/test/worker.test.cjs diff --git a/client.go b/client.go index c843ac1c..9f696954 100644 --- a/client.go +++ b/client.go @@ -26,7 +26,6 @@ import ( "github.com/columnar-tech/dbc/auth" "github.com/columnar-tech/dbc/internal" "github.com/google/uuid" - machineid "github.com/zeroshade/machine-id" ) type clientConfig struct { @@ -129,7 +128,7 @@ func NewClient(opts ...Option) (*Client, error) { func (c *Client) setup() { c.setupOnce.Do(func() { - c.mid, _ = machineid.ProtectedID() + c.mid, _ = telemetryMachineID() userdir, err := internal.GetUserConfigPath() if err != nil { @@ -167,6 +166,13 @@ func WithHTTPClient(hc *http.Client) Option { return func(cfg *clientConfig) { cfg.httpClient = hc } } +// WithCredentialResolver sets the resolver used to look up credentials for a +// registry URL, overriding the default on-disk resolver. Useful where there is +// no credential file (e.g. WASM hosts injecting a token). +func WithCredentialResolver(resolver func(*url.URL) (*auth.Credential, error)) Option { + return func(cfg *clientConfig) { cfg.credentialResolver = resolver } +} + // WithRegistries sets the driver registries to use. When passed, this takes // precedence over any WithGlobalConfig / WithProjectRegistries options: the // caller's explicit list is used as-is, not merged with configuration files. diff --git a/config/config.go b/config/config.go index e8ae8958..53206fd8 100644 --- a/config/config.go +++ b/config/config.go @@ -120,7 +120,7 @@ func (c *ConfigLevel) UnmarshalText(b []byte) error { func EnsureLocation(cfg Config) (string, error) { loc := cfg.Location if cfg.Level == ConfigEnv { - list := filepath.SplitList(loc) + list := splitConfigList(loc) if len(list) == 0 { return "", errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") } @@ -204,6 +204,30 @@ func loadConfig(lvl ConfigLevel) Config { return cfg } +// FindDriverConfigsIn lists installed drivers from an explicit location without +// consulting environment variables, so it is safe for request-scoped concurrent +// callers. A location containing the OS list separator is treated as a path list +// (matching the env config level). +func FindDriverConfigsIn(location string) []DriverInfo { + if location == "" { + return nil + } + paths := splitConfigList(location) + slices.Reverse(paths) + merged := make(map[string]DriverInfo) + for _, p := range paths { + if p == "" { + continue + } + drivers, err := loadDir(p) + if err != nil { + continue + } + maps.Copy(merged, drivers) + } + return slices.Collect(maps.Values(merged)) +} + func getEnvConfigDir() string { envConfigLoc := filepath.SplitList(os.Getenv(adbcEnvVar)) if venv := os.Getenv("VIRTUAL_ENV"); venv != "" { diff --git a/config/dirs_unixlike.go b/config/dirs_unixlike.go index 80b142c6..8e019f0f 100644 --- a/config/dirs_unixlike.go +++ b/config/dirs_unixlike.go @@ -93,7 +93,7 @@ func FindDriverConfigs(lvl ConfigLevel) []DriverInfo { func GetDriver(cfg Config, driverName string) (DriverInfo, error) { if cfg.Level == ConfigEnv { - for _, prefix := range filepath.SplitList(cfg.Location) { + for _, prefix := range splitConfigList(cfg.Location) { if di, err := loadDriverFromManifest(prefix, driverName); err == nil { return di, nil } diff --git a/config/findin_test.go b/config/findin_test.go new file mode 100644 index 00000000..3aaffee9 --- /dev/null +++ b/config/findin_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/columnar-tech/dbc/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const findInManifestTOML = ` +name = 'Test Driver' +publisher = 'Test Publisher' +license = 'MIT' +version = '1.2.3' +source = 'dbc' + +[ADBC] +version = '1.1.0' + +[Driver] +entrypoint = 'AdbcDriverInit' + +[Driver.shared] +linux_amd64 = '/path/to/driver.so' +` + +func TestFindDriverConfigsIn(t *testing.T) { + t.Run("lists drivers from an explicit location without env", func(t *testing.T) { + t.Setenv("ADBC_DRIVER_PATH", "/should/not/be/read") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "drv1.toml"), []byte(findInManifestTOML), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "drv2.toml"), []byte(findInManifestTOML), 0o644)) + + got := config.FindDriverConfigsIn(dir) + ids := make([]string, len(got)) + for i, d := range got { + ids[i] = d.ID + } + assert.ElementsMatch(t, []string{"drv1", "drv2"}, ids) + }) + + t.Run("empty location returns nil", func(t *testing.T) { + assert.Empty(t, config.FindDriverConfigsIn("")) + }) + + t.Run("nonexistent location returns empty", func(t *testing.T) { + assert.Empty(t, config.FindDriverConfigsIn(filepath.Join(t.TempDir(), "nope"))) + }) +} diff --git a/config/platform_override.go b/config/platform_override.go new file mode 100644 index 00000000..623bc991 --- /dev/null +++ b/config/platform_override.go @@ -0,0 +1,25 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js + +package config + +// SetPlatformTupleOverride sets the host platform tuple (e.g. "linux_amd64"). +// Required under GOARCH=wasm, where the detected tuple is "unknown_wasm64". It +// sets the package var directly so every reader observes it. Call once at +// startup before any install/uninstall. +func SetPlatformTupleOverride(tuple string) { + platformTuple = tuple +} diff --git a/config/splitlist_js.go b/config/splitlist_js.go new file mode 100644 index 00000000..903075fc --- /dev/null +++ b/config/splitlist_js.go @@ -0,0 +1,28 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js + +package config + +// splitConfigList does not split under GOOS=js: the wasm API passes a single +// explicit directory as the location, and ':' (the Unix list separator that +// filepath.SplitList uses under js) would corrupt a Windows drive-lettered +// path such as "C:/drivers". +func splitConfigList(s string) []string { + if s == "" { + return nil + } + return []string{s} +} diff --git a/config/splitlist_other.go b/config/splitlist_other.go new file mode 100644 index 00000000..e52319ba --- /dev/null +++ b/config/splitlist_other.go @@ -0,0 +1,21 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !js + +package config + +import "path/filepath" + +func splitConfigList(s string) []string { return filepath.SplitList(s) } diff --git a/credresolver_test.go b/credresolver_test.go new file mode 100644 index 00000000..9897bc45 --- /dev/null +++ b/credresolver_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dbc + +import ( + "net/url" + "testing" + + "github.com/columnar-tech/dbc/auth" +) + +func TestWithCredentialResolver(t *testing.T) { + want := &auth.Credential{} + var gotHost string + c, err := NewClient(WithCredentialResolver(func(u *url.URL) (*auth.Credential, error) { + gotHost = u.Host + return want, nil + })) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + got, err := c.credentialResolver(&url.URL{Host: "registry.example.com"}) + if err != nil { + t.Fatalf("resolver returned error: %v", err) + } + if gotHost != "registry.example.com" { + t.Fatalf("resolver host = %q, want registry.example.com", gotHost) + } + if got != want { + t.Fatal("resolver returned a different credential than provided") + } +} + +func TestWithCredentialResolverDefault(t *testing.T) { + c, err := NewClient() + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if c.credentialResolver == nil { + t.Fatal("default credentialResolver should be non-nil") + } +} diff --git a/drivers.go b/drivers.go index f34681c2..2bc7585b 100644 --- a/drivers.go +++ b/drivers.go @@ -38,7 +38,6 @@ import ( "github.com/columnar-tech/dbc/auth" "github.com/columnar-tech/dbc/internal" "github.com/google/uuid" - machineid "github.com/zeroshade/machine-id" ) var ( @@ -113,7 +112,7 @@ func ensureSetup() { }, } - mid, _ = machineid.ProtectedID() + mid, _ = telemetryMachineID() userdir, err := internal.GetUserConfigPath() if err != nil { diff --git a/packages/npm-wasm/.gitignore b/packages/npm-wasm/.gitignore new file mode 100644 index 00000000..87648b2c --- /dev/null +++ b/packages/npm-wasm/.gitignore @@ -0,0 +1,8 @@ +node_modules/ + +# Build artifacts generated by scripts/build.js — not committed +dbc.wasm +wasm_exec.js +package.json +LICENSE +*.tgz diff --git a/packages/npm-wasm/README.md b/packages/npm-wasm/README.md new file mode 100644 index 00000000..113d76f3 --- /dev/null +++ b/packages/npm-wasm/README.md @@ -0,0 +1,130 @@ + + +# @columnar-tech/dbc-wasm + +A WebAssembly build of [dbc](https://github.com/columnar-tech/dbc) that runs +in **Node.js** as an importable library — search registries, resolve versions, +install/uninstall ADBC drivers to disk, and verify signatures, without spawning +a subprocess. + +> For the command-line tool, use [`@columnar-tech/dbc`](https://www.npmjs.com/package/@columnar-tech/dbc) instead. + +## Requirements + +- Node.js >= 18 (uses global `fetch`; the loader wires Node's `fs`/`process`/`webcrypto` into the wasm runtime automatically). + +## Runtime support + +Validated on **Node.js >= 18** and **Deno** (`deno run -A`). **Bun is not currently +supported**: Bun's WebAssembly ↔ `node:fs` bridge mishandles the values Go's +`GOOS=js` runtime passes to fs callbacks, so file-writing operations +(`install`/`uninstall`) fail with `ERR_OUT_OF_RANGE`. This is an upstream Bun bug +([oven-sh/bun#32505](https://github.com/oven-sh/bun/issues/32505)), not a +limitation of this package; read-only `search`/`resolve` may still work. + +## Platform support + +Linux and macOS are validated in CI. **Windows host support is experimental and +not yet exercised in CI** — `windows-latest` is excluded from the WASM workflow +while driver discovery under `GOOS=js` is finished (tracked in +[#396](https://github.com/columnar-tech/dbc/issues/396)). The loader normalizes +Windows paths (backslashes to forward slashes; drive-relative to absolute) and +the Go config layer no longer splits a drive-lettered `location` on `:`, but the +end-to-end install/list round-trip is not yet validated on a Windows runtime. +Known Windows caveats: + +- Pass an explicit `location` (the npm API already does). Registry-backed + user/system config levels are unavailable under WASM. +- The manifest-symlink compatibility shim (for the ADBC Python driver-manager + <= 1.8.0) is inactive without Developer Mode; use driver-manager >= 1.8.1. + +## Usage + +```js +import { loadDbc } from "@columnar-tech/dbc-wasm"; + +const dbc = await loadDbc(); + +// Search the configured registries +const { drivers, warning } = await dbc.search("snowflake"); +if (warning) console.warn("some registries were unavailable:", warning); + +// Resolve versions + the latest package URL for the host platform +const info = await dbc.resolve("snowflake"); + +// Install to a directory (ADBC_DRIVER_PATH-style location) +const manifest = await dbc.install("snowflake", "/etc/adbc/drivers"); + +// List / uninstall +const installed = await dbc.listInstalled("/etc/adbc/drivers"); +await dbc.uninstall("snowflake", "/etc/adbc/drivers"); +``` + +CommonJS works too: + +```js +const { loadDbc } = require("@columnar-tech/dbc-wasm"); +``` + +### Options + +```js +await loadDbc({ + baseURL: "https://my-registry.example.com", // override the default registries + platform: "linux_amd64", // defaults to the detected Node host + credential: { // private-registry auth (OAuth refresh) + registryURL: "https://my-registry.example.com", + authURI: "https://my-registry.example.com", + token: "...", + refreshToken: "...", + clientID: "...", + }, + worker: true, // run the wasm in a Node Worker Thread +}); +``` + +`baseURL` and `credential` are **per-instance**: separate `loadDbc()` clients are +isolated and don't share registry config. `platform`, however, is applied +**process-globally** by the in-process backend (the last `loadDbc({ platform })` +wins), so concurrent in-process clients can't each pin a different default +platform. For per-call platform behavior, pass `platform` to `resolve(name, +platform)`, which takes precedence over the load-time default. + +Pass `worker: true` to run the runtime in a Node `worker_threads` Worker, so large +installs and signature verification stay off your app's event loop. **Always +`await dbc.close()` when done** under `worker: true` — the worker thread is not +garbage-collected, so a missing `close()` leaks the thread and keeps the Node +process alive. (In the default in-process backend `close()` is optional; the +handle is also released on GC.) + +For OAuth device-flow login, run the native `@columnar-tech/dbc` CLI +(`dbc auth login`) once; the WASM build reads and refreshes the stored +credentials, or you can inject a token via the `credential` option above. + +## Build + +The `.wasm` and `wasm_exec.js` are build artifacts. From the repo root: + +```sh +node packages/npm-wasm/scripts/build.js --version 0.3.0 +node packages/npm-wasm/test/smoke.cjs # optional smoke test +``` + +## Links + +- [GitHub Repo](https://github.com/columnar-tech/dbc) +- [Issues](https://github.com/columnar-tech/dbc/issues) diff --git a/packages/npm-wasm/boot.cjs b/packages/npm-wasm/boot.cjs new file mode 100644 index 00000000..35585909 --- /dev/null +++ b/packages/npm-wasm/boot.cjs @@ -0,0 +1,75 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +// curateGoEnv builds the minimal environment handed to the Go js/wasm runtime. +// wasm_exec.js caps the combined argv+env size; forwarding a full process.env +// (notably on Windows CI runners, whose environment is large) overflows that cap +// and makes go.run() throw before the runtime starts. The GOOS=js runtime reads +// only a few vars: $HOME (+ $XDG_*) for os.UserHomeDir/UserConfigDir/UserCacheDir, +// and $TMPDIR for os.TempDir() (it ignores %TEMP%/%TMP% and falls back to /tmp, +// which does not exist on a Windows host). Windows exposes the home/temp dirs as +// %USERPROFILE%/%TEMP%, so map them onto $HOME/$TMPDIR and convert backslashes to +// the forward slashes the wasm filesystem layer expects. +function curateGoEnv(env, platform) { + const isWin = platform === "win32"; + const norm = (p) => (isWin ? String(p).replace(/\\/g, "/") : String(p)); + const out = {}; + const home = env.HOME || env.USERPROFILE; + if (home !== undefined) out.HOME = norm(home); + const tmp = env.TMPDIR || env.TEMP || env.TMP; + if (tmp !== undefined) out.TMPDIR = norm(tmp); + for (const k of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]) { + if (env[k] !== undefined) out[k] = env[k]; + } + return out; +} + +// Single source of truth for instantiating the Go wasm runtime under Node. Both +// backends (in-process in index.cjs and the worker thread in worker.cjs) boot the +// module identically; any change to the boot protocol must apply to both, so it +// lives here exactly once. Callers keep their own error-reporting policy: the +// in-process path lets the rejection propagate, the worker posts a `fatal` +// message — but both share this instantiation sequence by construction. +// +// Throws an unprefixed Error on failure; callers add the `dbc-wasm:` namespace. +async function bootRuntime() { + // The upstream wasm_exec.js is browser-oriented. Under Node we must supply + // the real fs/process (and webcrypto on Node 18) BEFORE loading it, or Go's + // filesystem syscalls return "not implemented on js". + if (!globalThis.crypto) globalThis.crypto = require("crypto").webcrypto; + if (!globalThis.fs) globalThis.fs = fs; + if (!globalThis.process) globalThis.process = process; + + require("./wasm_exec.js"); // defines globalThis.Go + + const go = new globalThis.Go(); + go.env = curateGoEnv(process.env, process.platform); + + const bytes = fs.readFileSync(path.join(__dirname, "dbc.wasm")); + const { instance } = await WebAssembly.instantiate(bytes, go.importObject); + go.run(instance); // registers the dbc* globals, then parks on select{} + await new Promise((resolve) => setImmediate(resolve)); + + if (typeof globalThis.dbcSearch !== "function") { + throw new Error("runtime did not register its API"); + } +} + +module.exports = bootRuntime; +module.exports.curateGoEnv = curateGoEnv; diff --git a/packages/npm-wasm/index.cjs b/packages/npm-wasm/index.cjs new file mode 100644 index 00000000..152b78c6 --- /dev/null +++ b/packages/npm-wasm/index.cjs @@ -0,0 +1,261 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +const path = require("path"); +const bootRuntime = require("./boot.cjs"); + +const PLATFORM_MAP = { linux: "linux", darwin: "macos", win32: "windows", freebsd: "freebsd" }; +const ARCH_MAP = { x64: "amd64", arm64: "arm64", ia32: "x86" }; + +const ERROR_PREFIX = "dbc-wasm: "; + +// Every error this layer surfaces is namespaced with `dbc-wasm:` so consumers +// can attribute and string-match failures regardless of which backend (in-process +// or worker) or which Go-side call produced them. +function prefixError(e) { + const msg = e && e.message ? e.message : String(e); + if (msg.startsWith(ERROR_PREFIX)) return e instanceof Error ? e : new Error(msg); + return new Error(ERROR_PREFIX + msg); +} + +function hostPlatformTuple() { + const os = PLATFORM_MAP[process.platform]; + const arch = ARCH_MAP[process.arch]; + if (!os || !arch) { + throw new Error(`${ERROR_PREFIX}unsupported host platform ${process.platform}/${process.arch}`); + } + return `${os}_${arch}`; +} + +function normalizeLocation(loc) { + // Only on Windows: backslash is a legal filename character on POSIX, so POSIX + // locations must pass through untouched. On Windows, convert backslashes to + // forward slashes (Go's js/wasm filepath uses Unix semantics; Node fs accepts + // forward-slash drive paths) and make a drive-relative path absolute. + if (process.platform !== "win32") return String(loc); + let p = String(loc).replace(/\\/g, "/"); + p = p.replace(/^([A-Za-z]:)(?![/])/, "$1/"); + return p; +} + +let runtimePromise; + +function ensureRuntime() { + if (runtimePromise) return runtimePromise; + runtimePromise = (async () => { + try { + await bootRuntime(); + } catch (e) { + // Namespace the boot failure like every other error this layer surfaces. + throw prefixError(e); + } + // Platform is a process-global host constant; set it once at init. + try { + globalThis.dbcSetPlatform(hostPlatformTuple()); + } catch { + // Unsupported host: install/resolve default platform must be set via loadDbc({ platform }). + } + })(); + return runtimePromise; +} + +const clientFinalizer = + typeof FinalizationRegistry !== "undefined" + ? new FinalizationRegistry((handle) => { + try { + globalThis.dbcCloseClient(handle); + } catch { + // runtime already torn down; nothing to release + } + }) + : null; + +async function loadDbcWorker(opts) { + const { Worker } = require("worker_threads"); + const worker = new Worker(path.join(__dirname, "worker.cjs")); + + const pending = new Map(); + let nextId = 0; + let readyResolve, readyReject; + const ready = new Promise((res, rej) => { + readyResolve = res; + readyReject = rej; + }); + + let closed = false; + let hasInstall = false; + // Idempotently mark the client closed, failing both the init gate (`ready`) and + // every in-flight RPC with `err`. Returns false if it was already closed, so the + // first teardown wins and later events (e.g. an `exit` after an explicit + // `close()`) become no-ops. Routing the single `closed` toggle, the `readyReject` + // init gate, and the pending-rejection through one helper means every failure + // path settles the same two channels at once: a failing worker can never hang + // (something always rejects) nor double-settle (already-settled promises ignore + // later rejects, and the `closed` guard short-circuits repeat calls). + const markClosed = (err) => { + if (closed) return false; + closed = true; + readyReject(err); // no-op if `ready` already resolved/rejected + for (const p of pending.values()) p.reject(err); + pending.clear(); + return true; + }; + + worker.on("message", (msg) => { + if (msg.type === "ready") { + hasInstall = msg.hasInstall === true; + return readyResolve(); + } + if (msg.type === "fatal") { + // The worker runtime failed to initialize. markClosed surfaces it through + // `ready`; the init try/catch below terminates the worker so it cannot park + // on parentPort and keep the process alive. + markClosed(new Error("dbc-wasm: " + msg.error)); + return; + } + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + if (msg.ok) p.resolve(msg.value); + else p.reject(prefixError(new Error(msg.error))); + }); + worker.on("error", (e) => { + markClosed(prefixError(e)); + }); + worker.on("exit", (code) => { + // markClosed no-ops when close()/init-failure already tore down; in that + // expected case `ready` has settled and there is nothing left to reject. + markClosed(new Error(`dbc-wasm: worker exited unexpectedly (code ${code})`)); + }); + + const call = (fn, args) => { + if (closed) return Promise.reject(new Error("dbc-wasm: client is closed")); + return new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + worker.postMessage({ id, fn, args }); + }); + }; + + let handle; + try { + await ready; + await call("dbcSetPlatform", [opts.platform || hostPlatformTuple()]); + const cfg = JSON.stringify({ baseURL: opts.baseURL || "", credential: opts.credential || null }); + handle = await call("dbcNewClient", [cfg]); + } catch (e) { + // Initialization failed after the Worker was created (unsupported host + // platform, rejected dbcNewClient, or a worker fatal/error/exit). Terminate + // the worker before propagating so a failed loadDbc() never leaves a live + // wasm worker behind keeping the Node process alive. + markClosed(e); + await worker.terminate(); + throw e; + } + + return buildClient({ + call, + handle, + hasInstall, + close: async () => { + if (!markClosed(new Error(`${ERROR_PREFIX}client is closed`))) return; + await worker.terminate(); + }, + }); +} + +// Single source of truth for the public client shape. Both backends supply a +// `call(fn, args)` dispatcher and a `close()`; the object literal lives here only +// once so the two paths can never drift (consistent method set, error prefixing, +// and Promise close semantics by construction). +function buildClient({ call, handle, hasInstall, close }) { + const parse = async (fn, args) => JSON.parse(await call(fn, args)); + const api = { + search: (pattern = "") => parse("dbcSearch", [handle, pattern]), + resolve: (name, platform) => parse("dbcResolve", [handle, name, platform]), + verifySignature: (lib, sig) => call("dbcVerify", [lib, sig]), + close, + }; + // install/uninstall/listInstalled require a filesystem-capable build; both + // backends feature-detect identically so the surface is symmetric. + if (hasInstall) { + // dbcInstall takes the client handle (it needs the instance's registry + // config); dbcUninstall/dbcList are pure filesystem ops and intentionally + // do not — per-instance config does not apply to them. + api.install = (name, location) => parse("dbcInstall", [handle, name, normalizeLocation(location)]); + api.uninstall = async (name, location) => { + await call("dbcUninstall", [name, normalizeLocation(location)]); + }; + api.listInstalled = (location) => parse("dbcList", [normalizeLocation(location)]); + } + return api; +} + +async function loadDbc(opts = {}) { + if (opts.worker) return loadDbcWorker(opts); + await ensureRuntime(); + + // platform is a process-global host constant; only override when explicit. + if (opts.platform) globalThis.dbcSetPlatform(opts.platform); + + // Each instance gets its own Go-side client handle, so baseURL/credentials are + // isolated between instances and refreshed tokens persist across calls. + const cfg = JSON.stringify({ + baseURL: opts.baseURL || "", + credential: opts.credential || null, + }); + let handle; + try { + handle = await globalThis.dbcNewClient(cfg); + } catch (e) { + // Prefix load-time client construction failures (e.g. an invalid credential + // registryURL) so the in-process backend attributes errors the same way the + // worker backend and the rest of this layer do. + throw prefixError(e); + } + + let closed = false; + // In-process dispatcher: invoke the Go-registered global directly, prefixing + // any thrown error so attribution matches the worker backend. + const call = async (fn, args) => { + if (closed) throw new Error(`${ERROR_PREFIX}client is closed`); + const f = globalThis[fn]; + if (typeof f !== "function") throw new Error(`${ERROR_PREFIX}unknown wasm function: ${fn}`); + try { + return await f(...args); + } catch (e) { + throw prefixError(e); + } + }; + + const api = buildClient({ + call, + handle, + hasInstall: typeof globalThis.dbcInstall === "function", + // Guard against a double-free: a second close() (or one racing the + // finalizer) must not call dbcCloseClient on an already-released handle. + close: async () => { + if (closed) return; + closed = true; + if (clientFinalizer) clientFinalizer.unregister(api); + globalThis.dbcCloseClient(handle); + }, + }); + if (clientFinalizer) clientFinalizer.register(api, handle, api); + return api; +} + +module.exports = { loadDbc, hostPlatformTuple, normalizeLocation }; diff --git a/packages/npm-wasm/index.d.ts b/packages/npm-wasm/index.d.ts new file mode 100644 index 00000000..d9e3b66d --- /dev/null +++ b/packages/npm-wasm/index.d.ts @@ -0,0 +1,144 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export interface Driver { + path: string; + title: string; + license: string; + description: string; +} + +export interface SearchResult { + drivers: Driver[]; + /** + * Soft-failure channel: set when some registries were unreachable but a + * partial result is still returned. `search` aggregates across all configured + * registries, so a partial answer is meaningful; `resolve` targets a single + * named driver and has no equivalent — it either resolves or rejects. + */ + warning?: string; +} + +export interface ResolveResult { + path: string; + platform: string; + versions: string[]; + latest?: { version: string; url: string }; +} + +export interface Manifest { + id: string; + name: string; + version: string; + source: string; + driverPath: string; +} + +export interface InstalledDriver { + id: string; + name: string; + version: string; + filePath: string; +} + +export interface OAuthCredential { + registryURL: string; + authURI: string; + token?: string; + refreshToken?: string; + clientID?: string; +} + +export interface DbcOptions { + /** + * Driver registry base URL. Per-instance: isolated between `loadDbc` calls. + * Defaults to Columnar's public driver CDN (`https://dbc-cdn.columnar.tech`) + * when omitted; set it to point at a private or self-hosted registry instead. + */ + baseURL?: string; + /** + * Host platform tuple (e.g. "linux_amd64"). Defaults to the detected Node host. + * + * NOTE: unlike `baseURL`/`credential`, the in-process backend applies this + * **process-globally** (it is the last value passed to any `loadDbc` wins). + * Concurrent in-process instances with different platforms clobber each other. + * For per-call platform behavior, pass `platform` to `resolve()` instead, which + * takes precedence over this global default. + */ + platform?: string; + /** Credentials for a private registry, injected at runtime. Per-instance. */ + credential?: OAuthCredential; + /** Run the wasm runtime in a Node worker_threads Worker so heavy + * install/PGP work stays off the host event loop. Node-only. + * When `true`, `close()` is **mandatory** — the worker thread is not GC-managed, + * so a missing `close()` leaks the thread and keeps the Node process alive. */ + worker?: boolean; +} + +export interface Dbc { + search(pattern?: string): Promise; + /** + * Resolve versions + the latest package URL for a driver. + * @param platform Optional per-call platform tuple. When provided it takes + * precedence over `loadDbc({ platform })`; when omitted, the default + * platform set at load time is used. + */ + resolve(name: string, platform?: string): Promise; + /** + * Verify a driver library's detached signature. Trust is anchored solely to + * the Columnar signing key (`SignedByColumnar`); per-registry or caller-supplied + * trust anchors are a deliberate non-goal of this build. + */ + verifySignature(library: Uint8Array, signature: Uint8Array): Promise; + /** + * Release the underlying client handle (and, in worker mode, terminate the + * worker thread). Idempotent. In the in-process backend the handle is also + * auto-released on GC, so `close()` is optional there; under `worker: true` + * it is **required** to avoid leaking the worker thread. + */ + close(): Promise; + + /** + * Node-only (requires a filesystem). + * + * NOTE: `install`/`listInstalled` resolve the package via the **process-global** + * platform (see `DbcOptions.platform`) and have no per-call platform override. + * Concurrent in-process clients pinned to different platforms can therefore + * install or list the wrong platform's artifact; use the worker backend for + * multi-platform concurrency. + */ + install?(name: string, location: string): Promise; + uninstall?(name: string, location: string): Promise; + listInstalled?(location: string): Promise; +} + +/** Alias for {@link Dbc} that better signals the handle/lifecycle this object owns. */ +export type DbcClient = Dbc; + +export function loadDbc(opts?: DbcOptions): Promise; +/** + * The detected host platform tuple (e.g. "linux_amd64"). + * @throws if `process.platform`/`process.arch` is unsupported. + */ +export function hostPlatformTuple(): string; +/** + * Low-level path normalizer applied internally to every `location` argument. + * On non-Windows hosts it is the identity function; on Windows it converts + * backslashes to forward slashes and makes drive-relative paths absolute. + * + * Exposed only for tooling/tests — do NOT pre-normalize a `location` before + * passing it to `install`/`uninstall`/`listInstalled`, as those already + * normalize internally (double-normalizing is harmless but pointless). + */ +export function normalizeLocation(loc: string): string; diff --git a/packages/npm-wasm/index.mjs b/packages/npm-wasm/index.mjs new file mode 100644 index 00000000..5765eee8 --- /dev/null +++ b/packages/npm-wasm/index.mjs @@ -0,0 +1,20 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import cjs from "./index.cjs"; + +export const loadDbc = cjs.loadDbc; +export const hostPlatformTuple = cjs.hostPlatformTuple; +export const normalizeLocation = cjs.normalizeLocation; +export default cjs; diff --git a/packages/npm-wasm/scripts/build.js b/packages/npm-wasm/scripts/build.js new file mode 100644 index 00000000..a1a72abe --- /dev/null +++ b/packages/npm-wasm/scripts/build.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// build.js +// +// Builds the dbc WebAssembly module and assembles the @columnar-tech/dbc-wasm +// package: compiles ./wasm with -tags dbcnode, copies the matching wasm_exec.js +// from GOROOT, generates package.json at the given version, and copies LICENSE. +// +// Usage: node scripts/build.js [--version 0.3.0] + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const PKG_DIR = path.resolve(__dirname, ".."); +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); + +function parseVersion() { + const args = process.argv.slice(2); + const i = args.indexOf("--version"); + return (i !== -1 ? args[i + 1] : "0.0.0-dev").replace(/^v/, ""); +} + +function buildWasm() { + const out = path.join(PKG_DIR, "dbc.wasm"); + console.log("Building dbc.wasm (GOOS=js GOARCH=wasm -tags dbcnode)..."); + execFileSync("go", ["build", "-tags", "dbcnode", "-o", out, "./wasm"], { + cwd: REPO_ROOT, + env: { ...process.env, GOOS: "js", GOARCH: "wasm", GOWORK: "off" }, + stdio: "inherit", + }); + console.log(` -> ${out} (${fs.statSync(out).size} bytes)`); +} + +function copyWasmExec() { + const goroot = execFileSync("go", ["env", "GOROOT"]).toString().trim(); + const src = path.join(goroot, "lib", "wasm", "wasm_exec.js"); + const dest = path.join(PKG_DIR, "wasm_exec.js"); + fs.copyFileSync(src, dest); + console.log(` -> copied wasm_exec.js from ${src}`); +} + +function writePackageJson(version) { + const pkg = { + name: "@columnar-tech/dbc-wasm", + version, + description: "WebAssembly build of dbc for Node.js and Node.js-compatible runtimes: search, install, and manage ADBC drivers", + keywords: ["adbc", "arrow", "database", "drivers", "dbc", "wasm", "webassembly"], + homepage: "https://columnar.tech/dbc", + bugs: "https://github.com/columnar-tech/dbc/issues", + license: "Apache-2.0", + repository: { + type: "git", + url: "https://github.com/columnar-tech/dbc.git", + directory: "packages/npm-wasm", + }, + type: "commonjs", + main: "index.cjs", + module: "index.mjs", + types: "index.d.ts", + exports: { + ".": { + types: "./index.d.ts", + import: "./index.mjs", + require: "./index.cjs", + }, + }, + engines: { node: ">=18" }, + files: ["index.cjs", "index.mjs", "index.d.ts", "boot.cjs", "worker.cjs", "wasm_exec.js", "dbc.wasm", "README.md", "LICENSE"], + }; + fs.writeFileSync(path.join(PKG_DIR, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); + console.log(` -> wrote package.json at version ${version}`); +} + +function copyLicense() { + fs.copyFileSync(path.join(REPO_ROOT, "LICENSE"), path.join(PKG_DIR, "LICENSE")); +} + +function main() { + const version = parseVersion(); + buildWasm(); + copyWasmExec(); + writePackageJson(version); + copyLicense(); + console.log("\n@columnar-tech/dbc-wasm assembled."); +} + +try { + main(); +} catch (err) { + console.error(err.message); + process.exit(1); +} diff --git a/packages/npm-wasm/test/normalize.test.cjs b/packages/npm-wasm/test/normalize.test.cjs new file mode 100644 index 00000000..4709d9b0 --- /dev/null +++ b/packages/npm-wasm/test/normalize.test.cjs @@ -0,0 +1,112 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +const assert = require("assert"); +const { normalizeLocation } = require("../index.cjs"); +const { curateGoEnv } = require("../boot.cjs"); + +function withPlatform(platform, fn) { + const orig = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + try { + fn(); + } finally { + Object.defineProperty(process, "platform", orig); + } +} + +// POSIX: backslash is a legal filename character, so locations pass through +// unchanged (regression guard for roborev 6527). +withPlatform("linux", () => { + for (const p of ["/tmp/drivers", "/tmp/adbc\\drivers", "drivers\\test", "C:\\drivers"]) { + assert.strictEqual(normalizeLocation(p), p, `posix passthrough ${JSON.stringify(p)}`); + } +}); + +// Windows: backslashes -> forward slashes; drive-relative -> absolute. +withPlatform("win32", () => { + const cases = [ + ["/tmp/drivers", "/tmp/drivers"], + ["C:\\drivers", "C:/drivers"], + ["C:/drivers", "C:/drivers"], + ["C:\\a\\b\\c", "C:/a/b/c"], + ["C:drivers", "C:/drivers"], + ["C:", "C:/"], + ["d:\\Lower", "d:/Lower"], + ]; + for (const [input, want] of cases) { + assert.strictEqual(normalizeLocation(input), want, `win32 ${JSON.stringify(input)}`); + } +}); + +console.log("normalizeLocation: POSIX passthrough + Windows transform passed"); + +// curateGoEnv (roborev 6570): the Go js/wasm runtime is handed only the few env +// vars it reads, so a large host env can't overflow wasm_exec.js's argv/env cap; +// Windows %USERPROFILE%/%TEMP% map to $HOME/$TMPDIR (forward-slashed) because +// GOOS=js os.TempDir() reads only $TMPDIR (else /tmp, which is missing on Windows). +const ALLOWED_ENV_KEYS = ["HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]; + +// Windows: USERPROFILE -> HOME, TEMP -> TMPDIR, backslashes -> slashes, and +// arbitrary host vars (PATH/FOO/APPDATA) are dropped to bound the env size. +{ + const win = curateGoEnv( + { + USERPROFILE: "C:\\Users\\me", + TEMP: "C:\\Users\\me\\AppData\\Local\\Temp", + TMP: "C:\\nope", + PATH: "x".repeat(40000), + FOO: "bar", + APPDATA: "C:\\AppData", + }, + "win32" + ); + assert.strictEqual(win.HOME, "C:/Users/me", "win HOME from USERPROFILE"); + assert.strictEqual(win.TMPDIR, "C:/Users/me/AppData/Local/Temp", "win TMPDIR from TEMP"); + assert.deepStrictEqual(Object.keys(win).sort(), ["HOME", "TMPDIR"], "win env limited to mapped vars"); + for (const k of Object.keys(win)) assert(ALLOWED_ENV_KEYS.includes(k), `win unexpected key ${k}`); +} + +// Windows: an explicit TMPDIR wins over TEMP/TMP. +{ + const win = curateGoEnv({ TMPDIR: "X:\\explicit", TEMP: "C:\\temp" }, "win32"); + assert.strictEqual(win.TMPDIR, "X:/explicit", "win TMPDIR precedence"); +} + +// POSIX: HOME wins over USERPROFILE; XDG_* forwarded; PATH dropped; backslashes +// are NOT rewritten (legal filename chars on POSIX). +{ + const posix = curateGoEnv( + { + HOME: "/home/me", + USERPROFILE: "C:\\x", + TMPDIR: "/tmp", + XDG_CONFIG_HOME: "/home/me/.config", + PATH: "/usr/bin:/bin", + }, + "linux" + ); + assert.strictEqual(posix.HOME, "/home/me", "posix HOME"); + assert.strictEqual(posix.TMPDIR, "/tmp", "posix TMPDIR"); + assert.strictEqual(posix.XDG_CONFIG_HOME, "/home/me/.config", "posix XDG forwarded"); + assert(!("PATH" in posix), "posix PATH dropped"); + for (const k of Object.keys(posix)) assert(ALLOWED_ENV_KEYS.includes(k), `posix unexpected key ${k}`); +} + +// POSIX backslash passthrough: a home path containing a backslash is not mangled. +assert.strictEqual(curateGoEnv({ HOME: "/home/a\\b" }, "linux").HOME, "/home/a\\b", "posix backslash passthrough"); + +console.log("curateGoEnv: Windows TMPDIR/HOME mapping + POSIX passthrough + env bounded passed"); diff --git a/packages/npm-wasm/test/smoke.cjs b/packages/npm-wasm/test/smoke.cjs new file mode 100644 index 00000000..60a8b466 --- /dev/null +++ b/packages/npm-wasm/test/smoke.cjs @@ -0,0 +1,110 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const http = require("http"); +const os = require("os"); +const path = require("path"); + +const { loadDbc } = require(".."); + +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); +const indexData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test_index.yaml")); +const tarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-1.tar.gz")); + +const server = http.createServer((req, res) => { + if (req.url.startsWith("/index.yaml")) { + res.setHeader("Content-Type", "application/yaml"); + res.end(indexData); + } else if (req.url.includes(".tar.gz")) { + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Length", String(tarData.length)); + res.end(tarData); + } else { + res.statusCode = 404; + res.end("not found"); + } +}); + +function findFile(dir, suffix) { + for (const entry of fs.readdirSync(dir, { recursive: true })) { + if (entry.toString().endsWith(suffix)) return path.join(dir, entry.toString()); + } + return null; +} + +async function main() { + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const base = `http://127.0.0.1:${server.address().port}`; + + const dbc = await loadDbc({ baseURL: base, platform: "linux_amd64" }); + + const search = await dbc.search(""); + assert(Array.isArray(search.drivers) && search.drivers.length > 0, "search returned no drivers"); + + const resolved = await dbc.resolve("test-driver-1", "linux_amd64"); + assert(resolved.versions.length > 0, "resolve returned no versions"); + + const installDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbc-wasm-smoke-")); + const manifest = await dbc.install("test-driver-1", installDir); + assert(manifest.driverPath && fs.existsSync(manifest.driverPath), "installed driver missing on disk"); + + const installed = await dbc.listInstalled(installDir); + assert( + installed.length === 1 && installed[0].id === "test-driver-1", + `listInstalled mismatch: got ${JSON.stringify(installed)}; installDir entries: ${JSON.stringify(fs.readdirSync(installDir, { recursive: true }))}` + ); + + const so = findFile(installDir, ".so"); + const sig = findFile(installDir, ".sig"); + const ok = await dbc.verifySignature(new Uint8Array(fs.readFileSync(so)), new Uint8Array(fs.readFileSync(sig))); + assert(ok === true, "verifySignature failed for a valid signature"); + + await dbc.uninstall("test-driver-1", installDir); + const after = await dbc.listInstalled(installDir); + assert(after.length === 0, "driver still listed after uninstall"); + + // Regression guard (roborev 6562): in-process loadDbc() must namespace + // load-time client-construction failures with `dbc-wasm:`, matching the worker + // backend. An invalid credential registryURL (NUL byte) makes the underlying + // dbcNewClient reject; the error must surface through prefixError(). + let initErrorPrefixed = false; + try { + await loadDbc({ + baseURL: base, + platform: "linux_amd64", + credential: { registryURL: "http://\u0000", authURI: "http://example.test", token: "t" }, + }); + } catch (e) { + initErrorPrefixed = String(e && e.message ? e.message : e).startsWith("dbc-wasm:"); + } + assert(initErrorPrefixed, "in-process loadDbc() init failure should reject with a dbc-wasm:-prefixed error"); + + fs.rmSync(installDir, { recursive: true, force: true }); + server.close(); + console.log("SMOKE PASS:", { + drivers: search.drivers.length, + resolvedVersions: resolved.versions, + installed: manifest.id, + verified: ok, + }); +} + +main().catch((e) => { + console.error("SMOKE FAIL:", e && e.stack ? e.stack : e); + process.exit(1); +}); diff --git a/packages/npm-wasm/test/worker.test.cjs b/packages/npm-wasm/test/worker.test.cjs new file mode 100644 index 00000000..8279ae95 --- /dev/null +++ b/packages/npm-wasm/test/worker.test.cjs @@ -0,0 +1,113 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const http = require("http"); +const os = require("os"); +const path = require("path"); + +const { loadDbc } = require(".."); + +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); +const indexData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test_index.yaml")); +const tarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-1.tar.gz")); + +const server = http.createServer((req, res) => { + if (req.url.startsWith("/index.yaml")) { + res.setHeader("Content-Type", "application/yaml"); + res.end(indexData); + } else if (req.url.includes(".tar.gz")) { + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Length", String(tarData.length)); + res.end(tarData); + } else { + res.statusCode = 404; + res.end("not found"); + } +}); + +function findFile(dir, suffix) { + for (const entry of fs.readdirSync(dir, { recursive: true })) { + if (entry.toString().endsWith(suffix)) return path.join(dir, entry.toString()); + } + return null; +} + +async function main() { + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const base = `http://127.0.0.1:${server.address().port}`; + + const dbc = await loadDbc({ worker: true, baseURL: base, platform: "linux_amd64" }); + + const search = await dbc.search(""); + assert(search.drivers.length > 0, "search returned no drivers"); + + const installDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbc-wasm-worker-")); + const manifest = await dbc.install("test-driver-1", installDir); + assert(manifest.driverPath && fs.existsSync(manifest.driverPath), "installed driver missing on disk"); + + const installed = await dbc.listInstalled(installDir); + assert(installed.length === 1 && installed[0].id === "test-driver-1", "listInstalled mismatch"); + + const so = findFile(installDir, ".so"); + const sig = findFile(installDir, ".sig"); + const ok = await dbc.verifySignature(new Uint8Array(fs.readFileSync(so)), new Uint8Array(fs.readFileSync(sig))); + assert(ok === true, "verifySignature failed for a valid signature"); + + await dbc.uninstall("test-driver-1", installDir); + assert((await dbc.listInstalled(installDir)).length === 0, "driver still listed after uninstall"); + + await dbc.close(); + + // Regression guard (roborev 6533): after close() the worker is terminated, so + // calls must reject promptly via the `closed` state instead of posting to a + // dead worker and hanging forever (which would surface here as a CI timeout). + let rejectedAfterClose = false; + try { + await dbc.search(""); + } catch { + rejectedAfterClose = true; + } + assert(rejectedAfterClose, "search() after close() should reject, not hang"); + + // Regression guard (roborev 6537): when a setup RPC rejects after the Worker + // is created (here an invalid credential registryURL makes dbcNewClient + // reject), loadDbc must reject AND terminate the worker instead of leaving a + // live wasm worker behind. A leaked worker keeps the event loop alive and + // would hang this process on exit (surfacing as a CI timeout). + let initRejected = false; + try { + await loadDbc({ + worker: true, + baseURL: base, + platform: "linux_amd64", + credential: { registryURL: "http://\u0000", authURI: "http://example.test", token: "t" }, + }); + } catch { + initRejected = true; + } + assert(initRejected, "loadDbc({worker:true}) with an invalid credential should reject"); + + fs.rmSync(installDir, { recursive: true, force: true }); + server.close(); + console.log("WORKER SMOKE PASS:", { drivers: search.drivers.length, installed: manifest.id, verified: ok }); +} + +main().catch((e) => { + console.error("WORKER SMOKE FAIL:", e && e.stack ? e.stack : e); + process.exit(1); +}); diff --git a/packages/npm-wasm/worker.cjs b/packages/npm-wasm/worker.cjs new file mode 100644 index 00000000..be841c70 --- /dev/null +++ b/packages/npm-wasm/worker.cjs @@ -0,0 +1,48 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +// Worker entry for `loadDbc({ worker: true })`: instantiates the Go wasm runtime +// in this worker thread and answers {id, fn, args} RPC messages from the main +// thread, so heavy tar/PGP/install work doesn't block the host event loop. + +const { parentPort } = require("worker_threads"); +const bootRuntime = require("./boot.cjs"); + +(async () => { + try { + await bootRuntime(); + } catch (e) { + // The main thread surfaces this through `ready` and tears the worker down; + // report it instead of letting the rejection crash the worker silently. + parentPort.postMessage({ type: "fatal", error: e && e.message ? e.message : String(e) }); + return; + } + + parentPort.on("message", async (msg) => { + try { + const fn = globalThis[msg.fn]; + if (typeof fn !== "function") throw new Error(`unknown wasm function: ${msg.fn}`); + const value = await fn(...msg.args); + parentPort.postMessage({ id: msg.id, ok: true, value }); + } catch (e) { + parentPort.postMessage({ id: msg.id, ok: false, error: e && e.message ? e.message : String(e) }); + } + }); + + // Report optional-method availability so the main thread feature-detects the + // worker backend exactly as it does the in-process one (symmetric API surface). + parentPort.postMessage({ type: "ready", hasInstall: typeof globalThis.dbcInstall === "function" }); +})(); diff --git a/telemetry_other.go b/telemetry_other.go new file mode 100644 index 00000000..02ba7245 --- /dev/null +++ b/telemetry_other.go @@ -0,0 +1,24 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !js + +package dbc + +import machineid "github.com/zeroshade/machine-id" + +// telemetryMachineID delegates to the machine-id package on native platforms. +func telemetryMachineID() (string, error) { + return machineid.ProtectedID() +} diff --git a/telemetry_wasm.go b/telemetry_wasm.go new file mode 100644 index 00000000..b51de893 --- /dev/null +++ b/telemetry_wasm.go @@ -0,0 +1,22 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js + +package dbc + +// telemetryMachineID returns "" under js/wasm: machine-id has no wasm build. +func telemetryMachineID() (string, error) { + return "", nil +} diff --git a/wasm/api_js.go b/wasm/api_js.go new file mode 100644 index 00000000..f5a90f24 --- /dev/null +++ b/wasm/api_js.go @@ -0,0 +1,295 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "sync" + "syscall/js" + + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/auth" + "github.com/columnar-tech/dbc/config" +) + +type clientCredentialJSON struct { + RegistryURL string `json:"registryURL"` + AuthURI string `json:"authURI"` + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + ClientID string `json:"clientID"` +} + +type clientConfigJSON struct { + BaseURL string `json:"baseURL"` + Credential *clientCredentialJSON `json:"credential"` +} + +func init() { + // Route auth-internal HTTP (oauth/api-key refresh, license fetch use + // http.DefaultClient directly) through the JS fetch transport, since Go's + // default network is disabled under Node js/wasm. + client := &http.Client{Transport: fetchRoundTripper{}} + http.DefaultClient = client + dbc.DefaultClient = client +} + +// clientFromConfig builds a dbc.Client from a JSON config string ({baseURL, +// credential}). Each loadDbc instance gets its own client (stored by handle), +// so instances stay isolated AND a client's credential pointer persists across +// calls — refreshed access tokens are retained instead of being rebuilt from +// the original (possibly stale) token. An empty string yields a bare client. +func clientFromConfig(cfgJSON string) (*dbc.Client, error) { + opts := []dbc.Option{dbc.WithHTTPClient(&http.Client{Transport: fetchRoundTripper{}})} + if cfgJSON == "" { + return dbc.NewClient(opts...) + } + var cc clientConfigJSON + if err := json.Unmarshal([]byte(cfgJSON), &cc); err != nil { + return nil, fmt.Errorf("invalid client config: %w", err) + } + if cc.BaseURL != "" { + opts = append(opts, dbc.WithBaseURL(cc.BaseURL)) + } + if cc.Credential != nil { + regURL, err := url.Parse(cc.Credential.RegistryURL) + if err != nil { + return nil, fmt.Errorf("invalid credential registryURL: %w", err) + } + authURI, err := url.Parse(cc.Credential.AuthURI) + if err != nil { + return nil, fmt.Errorf("invalid credential authURI: %w", err) + } + cred := &auth.Credential{ + Type: auth.TypeToken, + AuthURI: auth.Uri(*authURI), + RegistryURL: auth.Uri(*regURL), + Token: cc.Credential.Token, + RefreshToken: cc.Credential.RefreshToken, + ClientID: cc.Credential.ClientID, + } + host := regURL.Host + opts = append(opts, dbc.WithCredentialResolver(func(u *url.URL) (*auth.Credential, error) { + if u.Host == host { + return cred, nil + } + return nil, nil + })) + } + return dbc.NewClient(opts...) +} + +var ( + clientsMu sync.Mutex + clients = map[int]*dbc.Client{} + nextClient int +) + +func registerClient(cfgJSON string) (int, error) { + c, err := clientFromConfig(cfgJSON) + if err != nil { + return 0, err + } + clientsMu.Lock() + defer clientsMu.Unlock() + nextClient++ + clients[nextClient] = c + return nextClient, nil +} + +func clientByHandle(h int) (*dbc.Client, error) { + clientsMu.Lock() + defer clientsMu.Unlock() + if c, ok := clients[h]; ok { + return c, nil + } + return nil, fmt.Errorf("invalid client handle %d", h) +} + +func releaseClient(h int) { + clientsMu.Lock() + delete(clients, h) + clientsMu.Unlock() +} + +func jsNewClient(args []js.Value) func() (any, error) { + cfgJSON := args[0].String() + return func() (any, error) { + h, err := registerClient(cfgJSON) + if err != nil { + return nil, err + } + return h, nil + } +} + +// registerCommon registers the API surface shared by the Node and browser +// builds: configuration hooks plus search/resolve/verify. +func registerCommon() { + js.Global().Set("dbcSetPlatform", js.FuncOf(func(_ js.Value, a []js.Value) any { + config.SetPlatformTupleOverride(a[0].String()) + return nil + })) + js.Global().Set("dbcNewClient", promisify(jsNewClient)) + js.Global().Set("dbcCloseClient", js.FuncOf(func(_ js.Value, a []js.Value) any { + releaseClient(a[0].Int()) + return nil + })) + js.Global().Set("dbcDebugPaths", promisify(jsDebugPaths)) + js.Global().Set("dbcSearch", promisify(jsSearch)) + js.Global().Set("dbcResolve", promisify(jsResolve)) + js.Global().Set("dbcVerify", promisify(jsVerify)) +} + +func toJSONValue(v any) (any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return string(b), nil +} + +func errString(e error) string { + if e == nil { + return "" + } + return e.Error() +} + +type driverDTO struct { + Path string `json:"path"` + Title string `json:"title"` + License string `json:"license"` + Description string `json:"description"` +} + +type searchResultDTO struct { + Drivers []driverDTO `json:"drivers"` + Warning string `json:"warning,omitempty"` +} + +func jsSearch(args []js.Value) func() (any, error) { + handle := args[0].Int() + pattern := "" + if len(args) > 1 { + pattern = args[1].String() + } + return func() (any, error) { + c, err := clientByHandle(handle) + if err != nil { + return nil, err + } + drivers, searchErr := c.Search(context.Background(), pattern) + if searchErr != nil && len(drivers) == 0 { + return nil, searchErr + } + out := searchResultDTO{Drivers: make([]driverDTO, 0, len(drivers))} + for _, d := range drivers { + out.Drivers = append(out.Drivers, driverDTO{Path: d.Path, Title: d.Title, License: d.License, Description: d.Desc}) + } + if searchErr != nil { + out.Warning = searchErr.Error() + } + return toJSONValue(out) + } +} + +type resolveDTO struct { + Path string `json:"path"` + Platform string `json:"platform"` + Versions []string `json:"versions"` + Latest *struct { + Version string `json:"version"` + URL string `json:"url"` + } `json:"latest,omitempty"` +} + +func jsResolve(args []js.Value) func() (any, error) { + handle := args[0].Int() + name := args[1].String() + platform := "" + if len(args) > 2 && args[2].Type() == js.TypeString { + platform = args[2].String() + } + return func() (any, error) { + if platform == "" { + platform = config.PlatformTuple() + } + c, err := clientByHandle(handle) + if err != nil { + return nil, err + } + drivers, searchErr := c.Search(context.Background(), name) + if searchErr != nil && len(drivers) == 0 { + return nil, searchErr + } + for _, d := range drivers { + if d.Path != name { + continue + } + versions := []string{} + for _, v := range d.Versions(platform) { + versions = append(versions, v.String()) + } + dto := resolveDTO{Path: d.Path, Platform: platform, Versions: versions} + if pkg, perr := d.GetPackage(nil, platform, false); perr == nil && pkg.Path != nil { + dto.Latest = &struct { + Version string `json:"version"` + URL string `json:"url"` + }{Version: pkg.Version.String(), URL: pkg.Path.String()} + } + return toJSONValue(dto) + } + if searchErr != nil { + return nil, fmt.Errorf("driver %q not found in reachable registries; some registries failed: %w", name, searchErr) + } + return nil, fmt.Errorf("driver %q not found", name) + } +} + +func jsVerify(args []js.Value) func() (any, error) { + lib := make([]byte, args[0].Get("length").Int()) + js.CopyBytesToGo(lib, args[0]) + sig := make([]byte, args[1].Get("length").Int()) + js.CopyBytesToGo(sig, args[1]) + return func() (any, error) { + if err := dbc.SignedByColumnar(bytes.NewReader(lib), bytes.NewReader(sig)); err != nil { + return nil, err + } + return true, nil + } +} + +func jsDebugPaths(_ []js.Value) func() (any, error) { + return func() (any, error) { + ucd, ucdErr := os.UserConfigDir() + home, homeErr := os.UserHomeDir() + return toJSONValue(map[string]any{ + "userConfigDir": ucd, + "userConfigDirErr": errString(ucdErr), + "userHomeDir": home, + "userHomeDirErr": errString(homeErr), + "platformTuple": config.PlatformTuple(), + }) + } +} diff --git a/wasm/bridge_js.go b/wasm/bridge_js.go new file mode 100644 index 00000000..39c3806b --- /dev/null +++ b/wasm/bridge_js.go @@ -0,0 +1,90 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js + +package main + +import ( + "fmt" + "syscall/js" +) + +func jsErr(msg string) js.Value { + return js.Global().Get("Error").New(msg) +} + +// promisify returns a JS function that yields a Promise. prepare runs +// synchronously so it can safely read JS args; its returned thunk runs on a +// goroutine so blocking Go work never stalls the single-threaded JS event loop. +func promisify(prepare func([]js.Value) func() (any, error)) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + work := prepare(args) + var executor js.Func + executor = js.FuncOf(func(_ js.Value, p []js.Value) any { + resolve, reject := p[0], p[1] + go func() { + defer executor.Release() + defer func() { + if r := recover(); r != nil { + reject.Invoke(jsErr(fmt.Sprintf("panic: %v", r))) + } + }() + res, err := work() + if err != nil { + reject.Invoke(jsErr(err.Error())) + return + } + resolve.Invoke(res) + }() + return nil + }) + return js.Global().Get("Promise").New(executor) + }) +} + +// await blocks the calling goroutine until the JS promise settles. It is safe +// only inside a goroutine (not a js.FuncOf callback), where a blocked goroutine +// yields control back to the JS event loop. +func await(promise js.Value) (js.Value, error) { + done := make(chan struct{}) + var result js.Value + var failure string + var failed bool + + onOK := js.FuncOf(func(_ js.Value, a []js.Value) any { + if len(a) > 0 { + result = a[0] + } + close(done) + return nil + }) + defer onOK.Release() + onErr := js.FuncOf(func(_ js.Value, a []js.Value) any { + failed = true + if len(a) > 0 { + failure = a[0].Call("toString").String() + } + close(done) + return nil + }) + defer onErr.Release() + + promise.Call("then", onOK).Call("catch", onErr) + <-done + if failed { + return js.Value{}, fmt.Errorf("%s", failure) + } + return result, nil +} diff --git a/wasm/main_browser.go b/wasm/main_browser.go new file mode 100644 index 00000000..f766a852 --- /dev/null +++ b/wasm/main_browser.go @@ -0,0 +1,36 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js && !dbcnode + +package main + +import "syscall/js" + +// Browser entrypoint (GOOS=js GOARCH=wasm, without -tags dbcnode). It exposes +// only the filesystem-free API (search/resolve/verifySignature) via +// registerCommon(). +// +// STATUS: experimental / not yet delivered. There is intentionally no browser +// loader, no npm `exports` entry, and no browser smoke test — the npm package +// (packages/npm-wasm) ships only the Node build. This seam exists so the browser +// target keeps compiling; finishing it (loader + tests + a published entry, and +// a browser .d.ts derived from packages/npm-wasm/index.d.ts, the single +// canonical type declaration) is a tracked future phase. Until then, treat the +// browser build as unsupported. +func main() { + registerCommon() + js.Global().Get("console").Call("log", "dbc-wasm (browser) ready") + select {} +} diff --git a/wasm/main_node.go b/wasm/main_node.go new file mode 100644 index 00000000..73454d17 --- /dev/null +++ b/wasm/main_node.go @@ -0,0 +1,26 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js && dbcnode + +package main + +import "syscall/js" + +func main() { + registerCommon() + registerNodeOps() + js.Global().Get("console").Call("log", "dbc-wasm (node) ready") + select {} +} diff --git a/wasm/ops_node_js.go b/wasm/ops_node_js.go new file mode 100644 index 00000000..06ab2ecd --- /dev/null +++ b/wasm/ops_node_js.go @@ -0,0 +1,103 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js && dbcnode + +package main + +import ( + "context" + "syscall/js" + + "github.com/columnar-tech/dbc/config" +) + +func registerNodeOps() { + js.Global().Set("dbcInstall", promisify(jsInstall)) + js.Global().Set("dbcList", promisify(jsList)) + js.Global().Set("dbcUninstall", promisify(jsUninstall)) +} + +type manifestDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Source string `json:"source"` + DriverPath string `json:"driverPath"` +} + +func jsInstall(args []js.Value) func() (any, error) { + handle := args[0].Int() + name := args[1].String() + location := args[2].String() + return func() (any, error) { + c, err := clientByHandle(handle) + if err != nil { + return nil, err + } + m, err := c.Install(context.Background(), config.Config{Level: config.ConfigEnv, Location: location}, name) + if err != nil { + return nil, err + } + version := "" + if m.DriverInfo.Version != nil { + version = m.DriverInfo.Version.String() + } + return toJSONValue(manifestDTO{ + ID: m.DriverInfo.ID, + Name: m.DriverInfo.Name, + Version: version, + Source: m.DriverInfo.Source, + DriverPath: m.DriverInfo.Driver.Shared.Get(config.PlatformTuple()), + }) + } +} + +type driverInfoDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + FilePath string `json:"filePath"` +} + +func jsList(args []js.Value) func() (any, error) { + location := args[0].String() + return func() (any, error) { + drivers := config.FindDriverConfigsIn(location) + out := make([]driverInfoDTO, 0, len(drivers)) + for _, d := range drivers { + version := "" + if d.Version != nil { + version = d.Version.String() + } + out = append(out, driverInfoDTO{ID: d.ID, Name: d.Name, Version: version, FilePath: d.FilePath}) + } + return toJSONValue(out) + } +} + +func jsUninstall(args []js.Value) func() (any, error) { + name := args[0].String() + location := args[1].String() + return func() (any, error) { + c, err := clientFromConfig("") + if err != nil { + return nil, err + } + if err := c.Uninstall(config.Config{Level: config.ConfigEnv, Location: location}, name); err != nil { + return nil, err + } + return "ok", nil + } +} diff --git a/wasm/roundtripper_js.go b/wasm/roundtripper_js.go new file mode 100644 index 00000000..b234e95d --- /dev/null +++ b/wasm/roundtripper_js.go @@ -0,0 +1,136 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js + +package main + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + "syscall/js" +) + +// fetchRoundTripper implements http.RoundTripper by delegating to the host's JS +// fetch(); Go's own network stack is unavailable/disabled under Node js/wasm. +type fetchRoundTripper struct{} + +func (fetchRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + opts := map[string]any{"method": req.Method} + + headers := map[string]any{} + for k, v := range req.Header { + headers[k] = strings.Join(v, ", ") + } + opts["headers"] = headers + + if req.Body != nil { + body, err := io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + if len(body) > 0 { + buf := js.Global().Get("Uint8Array").New(len(body)) + js.CopyBytesToJS(buf, body) + opts["body"] = buf + } + } + + respVal, err := await(js.Global().Call("fetch", req.URL.String(), js.ValueOf(opts))) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", req.URL, err) + } + + header := http.Header{} + collect := js.FuncOf(func(_ js.Value, a []js.Value) any { + header.Add(a[1].String(), a[0].String()) + return nil + }) + respVal.Get("headers").Call("forEach", collect) + collect.Release() + + status := respVal.Get("status").Int() + resp := &http.Response{ + StatusCode: status, + Status: fmt.Sprintf("%d %s", status, respVal.Get("statusText").String()), + Header: header, + ContentLength: parseContentLength(header), + Request: req, + } + + // Stream the body via the ReadableStream reader rather than buffering the + // whole response, so large driver tarballs are copied to disk chunk-by-chunk + // instead of being held in wasm linear memory. + body := respVal.Get("body") + if body.IsNull() || body.IsUndefined() { + resp.Body = http.NoBody + } else { + resp.Body = &jsStreamBody{reader: body.Call("getReader")} + } + return resp, nil +} + +func parseContentLength(h http.Header) int64 { + if cl := h.Get("Content-Length"); cl != "" { + if n, err := strconv.ParseInt(cl, 10, 64); err == nil { + return n + } + } + return -1 +} + +// jsStreamBody adapts a JS ReadableStreamDefaultReader to an io.ReadCloser. Read +// awaits, so it must run on a goroutine (not a js.FuncOf callback). +type jsStreamBody struct { + reader js.Value + buf []byte + done bool +} + +func (b *jsStreamBody) Read(p []byte) (int, error) { + for len(b.buf) == 0 { + if b.done { + return 0, io.EOF + } + res, err := await(b.reader.Call("read")) + if err != nil { + return 0, err + } + if res.Get("done").Bool() { + b.done = true + continue + } + chunk := res.Get("value") + n := chunk.Get("length").Int() + if n == 0 { + continue + } + b.buf = make([]byte, n) + js.CopyBytesToGo(b.buf, chunk) + } + n := copy(p, b.buf) + b.buf = b.buf[n:] + return n, nil +} + +func (b *jsStreamBody) Close() error { + if !b.done { + _, _ = await(b.reader.Call("cancel")) + } + return nil +}