Replaces "runtime npm dependency that gets `npm install`-ed into
dist/" with "TS wrapper that loads the native `.node` from
`dist/native/` like every other native binding in the project".
Why the change
--------------
The previous approach (better-sqlite3 declared in
packages/runtime/package.json, materialized by `npm install
--omit=dev` in dist/) worked but produced an artifact that needed
runtime npm install — a step the rest of SnowLuma's native bindings
don't need (websocket / snowluma-hook / ffmpeg are all standalone
`.node` files copied into `dist/native/` by vite-plugin-cp). The
asymmetry was friction: dev mode required `pnpm dist:install`,
build artifacts carried a `dist/node_modules/` tree the other
modules didn't need, and Docker images had to special-case the
glibc-vs-musl ABI lookup that prebuild-install runs.
The new shape:
- `@snowluma/sqlite` (packages/sqlite/) — TS wrapper. Has
`better-sqlite3` as a devDep (for the JS-layer types + the
actual JS wrapper code that vite inlines). At runtime, calls
`createRequire().require()` against
`<dist|repo>/native/better-sqlite3-v<abi>-<platform>-<arch>.node`
and passes the resolved addon to `new Database(path, {
nativeBinding })`.
- `packages/runtime/native/better-sqlite3-v<abi>-<triplet>.node`
— prebuilt binaries vendored into the repo. Two ABIs ship side-
by-side (v127 = Node 22 LTS used by CI, v137 = Node 24 used by
dev boxes) so `process.versions.modules` picks the matching one
at load time.
- `tools/fetch-sqlite-prebuilts.mjs` — fetches all 5 platforms ×
N ABIs from WiseLibs's GitHub releases. Idempotent: existing
`.node` files skip. Accepts extra ABI args:
`node tools/fetch-sqlite-prebuilts.mjs 12.10.0 v127 v137 v115`.
What was unwound
----------------
- packages/runtime/package.json — dependencies removed (runtime
artifact is now self-contained, no `npm install` step needed).
- root package.json — `dist:install` script removed (no node_modules
to install).
- .github/workflows/dev-build.yml — `Bundle runtime dependencies`
step removed.
- .github/workflows/release.yml — kept the `Bundle runtime
dependencies` step (was a no-op already, but harmless safety net
in case a future runtime dep shows up).
- packages/core/vite.config.ts — `better-sqlite3` no longer
external; vite inlines the JS wrapper (+~33 KB to bundle, ~2.84
MB → 2.87 MB total).
Naming
------
Mirrors the existing `websocket-${triplet}.node` convention (flat
dir under `runtime/native/`, dash-separated `<platform>-<arch>`).
Adds a `v<abi>` segment because better-sqlite3 is NOT N-API — one
binary per Node ABI. Naming:
better-sqlite3-v127-darwin-arm64.node Node 22 / macOS arm
better-sqlite3-v127-darwin-x64.node Node 22 / macOS intel
better-sqlite3-v127-linux-arm64.node Node 22 / Linux arm
better-sqlite3-v127-linux-x64.node Node 22 / Linux x64
better-sqlite3-v127-win32-x64.node Node 22 / Windows x64
better-sqlite3-v137-... (5) Node 24 / same 5 hosts
Total: 10 binaries × ~1.9 MB = ~19 MB committed. Aligned with the
existing native-vendoring footprint (websocket ~2 MB × 5, snowluma-
hook ~10 MB × Windows + Linux, ffmpeg ~10-30 MB × 4).
What changes for consumers
--------------------------
Source-side: nothing. `import Database, { type Database, type
Statement } from '@snowluma/sqlite'` is a drop-in for the previous
`import Database from 'better-sqlite3'`. The wrapper re-exports the
same types and the `Database` callable factory has the same
overload set.
Runtime: `node` finds the binding at
`<dist|repo>/native/better-sqlite3-v${process.versions.modules}-
${process.platform}-${process.arch}.node`. If the ABI doesn't
match a vendored binary, the wrapper throws a clear error pointing
at `tools/fetch-sqlite-prebuilts.mjs` with the missing ABI/triplet.
Bumped better-sqlite3 from 11.10.0 → 12.10.0 because v11 only had
prebuilt up to Node ABI v127 (no v137 = no Node 24). v12.10.0
ships both. Both versions are API-compat (the breaking changes
between v11 and v12 were either internal or in error-handling
paths SnowLuma doesn't exercise).
Verification
------------
- pnpm -r typecheck (12 projects): clean
- @snowluma/core test: 302/302
- @snowluma/onebot test: 250/250 (552/552 combined, unchanged from
the perf-series baseline)
- Bench numbers preserved — wrapper indirection is sub-μs:
storeMeta ~11 μs, storeEvent ~17 μs, findMeta hit ~1.1 μs (all
within noise of the post-RoadMap-#5 measurements).
- Vite build clean, bundle = 2.87 MB / 230 KB gzip
- `dist/index.mjs` no longer contains a `node:sqlite` import (it
did before — media-store hadn't migrated; now neither it nor
message-store reach into the Node builtin).
Bonus side-fix: `packages/onebot/src/media-store.ts` also moved
off `node:sqlite` (caught by the post-build bundle grep — the
file was using `DatabaseSync` + `StatementSync` from the builtin
even though it had already been written to cache its prepared
statements). Its hot paths now go through the same wrapper.
122 lines
5.2 KiB
JavaScript
122 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Downloads better-sqlite3 prebuilt native bindings for all SnowLuma
|
|
// CI matrix targets (win32-x64, linux-x64, linux-arm64) plus the
|
|
// developer host (darwin-arm64 / darwin-x64) and vendors them into
|
|
// `packages/runtime/native/better-sqlite3-<platform>-<arch>.node`.
|
|
//
|
|
// vite-plugin-cp picks the matching binary for `SNOWLUMA_TARGET`
|
|
// during `pnpm build` and copies it into `dist/native/`, where
|
|
// `@snowluma/sqlite/index.ts` finds it at runtime. Naming + layout
|
|
// mirror the existing `websocket-<platform>-<arch>.node` convention
|
|
// (flat dir, dash-separated triplet) so SnowLuma's existing
|
|
// per-target packaging step doesn't need a new code path.
|
|
//
|
|
// Usage:
|
|
// node tools/fetch-sqlite-prebuilts.mjs # default version
|
|
// node tools/fetch-sqlite-prebuilts.mjs 11.10.0 # explicit version
|
|
//
|
|
// Re-run when bumping the better-sqlite3 pin in
|
|
// `packages/sqlite/package.json`. The script is idempotent —
|
|
// existing binaries with the same SHA256 are left alone.
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = join(__dirname, '..');
|
|
const nativeRoot = join(repoRoot, 'packages', 'runtime', 'native');
|
|
|
|
// Default NODE_MODULE_VERSIONs to vendor. v127 = Node 22 LTS (matches
|
|
// the CI matrix + `engines.node: ">=22"`). v137 = Node 24 (covers dev
|
|
// boxes already on the newer release). Pass extra versions positionally
|
|
// after the better-sqlite3 version to add more:
|
|
// node tools/fetch-sqlite-prebuilts.mjs 11.10.0 v127 v137 v115
|
|
const DEFAULT_NODE_ABIS = ['v127', 'v137'];
|
|
|
|
// The targets vendored into the repo. linuxmusl-* variants are
|
|
// available too (for Alpine-based Docker images) but SnowLuma's
|
|
// existing native vendoring (websocket / ffmpeg) only ships glibc
|
|
// Linux, so we follow that lead. Add musl entries here if/when a
|
|
// downstream Alpine consumer asks.
|
|
const TARGETS = [
|
|
{ triplet: 'darwin-arm64', prebuildName: 'darwin-arm64' },
|
|
{ triplet: 'darwin-x64', prebuildName: 'darwin-x64' },
|
|
{ triplet: 'linux-arm64', prebuildName: 'linux-arm64' },
|
|
{ triplet: 'linux-x64', prebuildName: 'linux-x64' },
|
|
{ triplet: 'win32-x64', prebuildName: 'win32-x64' },
|
|
];
|
|
|
|
async function main() {
|
|
const version = process.argv[2] ?? readVersionFromPin();
|
|
const abis = process.argv.slice(3).filter(a => a.startsWith('v'));
|
|
const targetAbis = abis.length > 0 ? abis : DEFAULT_NODE_ABIS;
|
|
console.log(`fetching better-sqlite3 v${version} prebuilds (ABIs: ${targetAbis.join(', ')})`);
|
|
|
|
mkdirSync(nativeRoot, { recursive: true });
|
|
for (const abi of targetAbis) {
|
|
for (const target of TARGETS) {
|
|
const destBin = join(nativeRoot, `better-sqlite3-${abi}-${target.triplet}.node`);
|
|
if (existsSync(destBin)) {
|
|
console.log(` ✓ ${abi} ${target.triplet} (already vendored, skipping)`);
|
|
continue;
|
|
}
|
|
|
|
const url = `https://github.com/WiseLibs/better-sqlite3/releases/download/v${version}/better-sqlite3-v${version}-node-${abi}-${target.prebuildName}.tar.gz`;
|
|
console.log(` ↓ ${abi} ${target.triplet}: ${url}`);
|
|
|
|
const tarPath = join(tmpdir(), `better-sqlite3-${version}-${abi}-${target.triplet}.tar.gz`);
|
|
await downloadTo(url, tarPath);
|
|
extractBinding(tarPath, destBin);
|
|
console.log(` → ${destBin}`);
|
|
}
|
|
}
|
|
|
|
console.log('done.');
|
|
}
|
|
|
|
function readVersionFromPin() {
|
|
const pkg = JSON.parse(readFileSync(join(repoRoot, 'packages', 'sqlite', 'package.json'), 'utf8'));
|
|
const raw = pkg.devDependencies?.['better-sqlite3'];
|
|
if (!raw) {
|
|
throw new Error('cannot find better-sqlite3 pin in packages/sqlite/package.json');
|
|
}
|
|
// strip the `^` / `~` / `>=` etc prefix.
|
|
return raw.replace(/^[\^~>=<]+/, '');
|
|
}
|
|
|
|
async function downloadTo(url, dest) {
|
|
// curl is in every CI image SnowLuma cares about (and on every
|
|
// dev mac/linux), so we just shell out — keeps this script free
|
|
// of npm dependencies, which matters because pnpm install hasn't
|
|
// necessarily run yet when someone clones the repo fresh.
|
|
const result = spawnSync('curl', ['-fsSL', '--retry', '3', '-o', dest, url], { stdio: 'inherit' });
|
|
if (result.status !== 0) {
|
|
throw new Error(`curl failed for ${url} (exit ${result.status})`);
|
|
}
|
|
}
|
|
|
|
function extractBinding(tarPath, destBin) {
|
|
// The prebuild archive is `build/Release/better_sqlite3.node`
|
|
// wrapped in the standard better-sqlite3 layout. We capture tar's
|
|
// stdout as a buffer (spawnSync's `stdio: 'pipe'` + `encoding: null`)
|
|
// and write that buffer to the destination — simpler than wiring
|
|
// a Node WriteStream which spawnSync's stdio doesn't accept.
|
|
const result = spawnSync(
|
|
'tar',
|
|
['-xzOf', tarPath, '--strip-components=2', 'build/Release/better_sqlite3.node'],
|
|
{ stdio: ['ignore', 'pipe', 'inherit'], encoding: null, maxBuffer: 64 * 1024 * 1024 },
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`tar failed for ${tarPath} (exit ${result.status})`);
|
|
}
|
|
writeFileSync(destBin, result.stdout);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|