mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 10:42:31 +00:00
nodejs: lazy per-backend libsimplex download (#7543)
* ci: drop dlltool from nodejs libs release * nodejs: download libsimplex into the user cache * nodejs: load libsimplex at runtime per backend * support bot: select db backend with --db * python: match backend switch error text * nodejs: download prebuilt add-on at runtime * ci: build nodejs add-on for libs release * calculator bot: load libs and add-on in image * support bot: document add-on override * nodejs: keep worker test output free of colors * nodejs: verify sha-256 of downloaded assets * python: pin core libs release with sha-256 * ci: add packages release script * ci: release nodejs add-on and open release PR * ci: publish packages after release PR * nodejs: keep npm from building the add-on * calculator bot: build the add-on in the image * ci: merge package release workflows * ci: open release PR when publishing fails * ci: build the add-on with node-addon-api only * ci: skip publishing in a called packages workflow * ci: update package release actions to Node 24 * nodejs: fix receive test timeout * ci: test packages on pull requests * nodejs: reuse the database in the close stress test * ci: keep failed package tests from hanging * ci: skip preset server tests * ci: plan package builds from the changes * ci: test packages with a given libsimplex * ci: test packages with libsimplex of core changes
This commit is contained in:
73 files changed
+2823
-650
No files matched your search
@@ -57,10 +57,8 @@ interface EventSubscriber<K extends CEvt.Tag> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Database configuration. The native library is built against exactly one
|
||||
* backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this
|
||||
* type makes the caller state which one they are targeting so field names
|
||||
* can't lie about their meaning.
|
||||
* Database configuration. `type` selects the libsimplex backend loaded by
|
||||
* `ChatApi.init`; one backend per process.
|
||||
*/
|
||||
export type DbConfig =
|
||||
| {
|
||||
@@ -108,7 +106,7 @@ export class ChatApi {
|
||||
private constructor(protected ctrl_: bigint | undefined) {}
|
||||
|
||||
/**
|
||||
* Initializes the ChatApi.
|
||||
* Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use).
|
||||
* @param {DbConfig} db - Database configuration (sqlite or postgres).
|
||||
* @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode.
|
||||
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
|
||||
@@ -119,6 +117,7 @@ export class ChatApi {
|
||||
queueSize?: number
|
||||
): Promise<ChatApi> {
|
||||
const [path, key] = dbConfigToMigrateArgs(db)
|
||||
await core.loadLibrary(db.type)
|
||||
const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize)
|
||||
return new ChatApi(ctrl)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
import {parseArgs} from "util"
|
||||
import type {Backend} from "./core"
|
||||
import {resolveAddonPath, resolveLibsDir} from "./libs"
|
||||
|
||||
const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]"
|
||||
const EXIT_INSTALL_FAILED = 1
|
||||
const EXIT_USAGE = 2
|
||||
|
||||
export function parseInstallArgs(argv: string[]): Backend {
|
||||
const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}})
|
||||
if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install")
|
||||
if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`)
|
||||
return backend
|
||||
}
|
||||
|
||||
export async function main(argv: string[]): Promise<number> {
|
||||
if (argv.includes("-h") || argv.includes("--help")) {
|
||||
console.log(USAGE)
|
||||
return 0
|
||||
}
|
||||
let backend: Backend
|
||||
try {
|
||||
backend = parseInstallArgs(argv)
|
||||
} catch (e) {
|
||||
console.error(`${(e as Error).message}\n${USAGE}`)
|
||||
return EXIT_USAGE
|
||||
}
|
||||
try {
|
||||
const [dir, addon] = await Promise.all([resolveLibsDir(backend), resolveAddonPath()])
|
||||
console.log(`libsimplex installed at: ${dir}`)
|
||||
console.log(`Node.js add-on installed at: ${addon}`)
|
||||
return 0
|
||||
} catch (e) {
|
||||
console.error(`install failed: ${(e as Error).message}`)
|
||||
return EXIT_INSTALL_FAILED
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main(process.argv.slice(2)).then(code => { process.exitCode = code })
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
import {ChatEvent, ChatResponse, T} from "@simplex-chat/types"
|
||||
import * as libs from "./libs"
|
||||
import * as simplex from "./simplex"
|
||||
|
||||
export type Backend = "sqlite" | "postgres"
|
||||
|
||||
let loading: {backend: Backend, promise: Promise<void>} | undefined
|
||||
|
||||
/**
|
||||
* Resolve (downloading on first use) and load libsimplex for the backend.
|
||||
* One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected.
|
||||
*/
|
||||
export function loadLibrary(backend: Backend): Promise<void> {
|
||||
if (loading) {
|
||||
if (loading.backend === backend) return loading.promise
|
||||
return Promise.reject(new Error(`libsimplex already loaded with backend=${loading.backend}; cannot switch to ${backend} in the same process`))
|
||||
}
|
||||
const promise = Promise.all([libs.resolveLibsDir(backend), libs.resolveAddonPath()])
|
||||
.then(([dir, addon]) => simplex.load(addon, libs.libPath(dir)))
|
||||
loading = {backend, promise}
|
||||
// a failed download or load can be retried
|
||||
promise.catch(() => { loading = undefined })
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize chat controller
|
||||
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const extract = require('extract-zip');
|
||||
|
||||
const GITHUB_REPO = 'simplex-chat/simplex-chat-libs';
|
||||
const RELEASE_TAG = 'core-7.1.0.11';
|
||||
const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase();
|
||||
// A locally built libsimplex, copied into libs/ rather than loaded in place:
|
||||
// the addon's RUNPATH is $ORIGIN/../../libs.
|
||||
const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir;
|
||||
const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll'];
|
||||
|
||||
if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
|
||||
console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (BACKEND === 'postgres' && (process.platform !== 'linux' || process.arch !== 'x64')) {
|
||||
console.error(`✗ SIMPLEX_BACKEND=postgres is only supported on Linux x86_64.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ROOT_DIR = process.cwd(); // Root of the package being installed
|
||||
const LIBS_DIR = path.join(ROOT_DIR, 'libs')
|
||||
const INSTALLED_FILE = path.join(LIBS_DIR, 'installed.txt');
|
||||
|
||||
// Detect platform and architecture
|
||||
function getPlatformInfo() {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let platformName;
|
||||
let archName;
|
||||
|
||||
if (platform === 'linux') {
|
||||
platformName = 'linux';
|
||||
} else if (platform === 'darwin') {
|
||||
platformName = 'macos';
|
||||
} else if (platform === 'win32') {
|
||||
platformName = 'windows';
|
||||
} else {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
if (arch === 'x64') {
|
||||
archName = 'x86_64';
|
||||
} else if (arch === 'arm64') {
|
||||
archName = 'aarch64';
|
||||
} else {
|
||||
throw new Error(`Unsupported architecture: ${arch}`);
|
||||
}
|
||||
|
||||
return { platformName, archName };
|
||||
}
|
||||
|
||||
// Cleanup on libs version mismatch
|
||||
function cleanLibsDirectory() {
|
||||
if (fs.existsSync(LIBS_DIR)) {
|
||||
console.log('Cleaning old libraries...');
|
||||
fs.rmSync(LIBS_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(LIBS_DIR, { recursive: true });
|
||||
console.log('✓ Old libraries removed');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if libraries are already installed with the correct version
|
||||
function isAlreadyInstalled() {
|
||||
if (!fs.existsSync(INSTALLED_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const installedVersion = fs.readFileSync(INSTALLED_FILE, 'utf-8').trim();
|
||||
const expectedVersion = `${RELEASE_TAG}:${BACKEND}`;
|
||||
if (installedVersion === expectedVersion) {
|
||||
console.log(`✓ Libraries version ${RELEASE_TAG}:${BACKEND} already installed`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`Version mismatch: installed ${installedVersion}, need ${expectedVersion}`);
|
||||
cleanLibsDirectory();
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not read installed.txt: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild.
|
||||
function installFromOverride() {
|
||||
if (!fs.existsSync(LIBS_DIR_OVERRIDE)) {
|
||||
throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`);
|
||||
}
|
||||
const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name)));
|
||||
if (!lib) {
|
||||
throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`);
|
||||
}
|
||||
console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`);
|
||||
cleanLibsDirectory();
|
||||
copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR);
|
||||
// Not a release tag: a later install without the variable sees a mismatch
|
||||
// and replaces these files with the released ones.
|
||||
fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8');
|
||||
console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`);
|
||||
}
|
||||
|
||||
async function install() {
|
||||
try {
|
||||
if (LIBS_DIR_OVERRIDE) {
|
||||
installFromOverride();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if (isAlreadyInstalled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { platformName, archName } = getPlatformInfo();
|
||||
const repoName = GITHUB_REPO.split('/')[1];
|
||||
const backendSuffix = BACKEND === 'postgres' ? '-postgres' : '';
|
||||
const zipFilename = `${repoName}-${platformName}-${archName}${backendSuffix}.zip`;
|
||||
const ZIP_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${zipFilename}`;
|
||||
const ZIP_PATH = path.join(ROOT_DIR, zipFilename);
|
||||
const TEMP_EXTRACT_DIR = path.join(ROOT_DIR, '.temp-extract');
|
||||
|
||||
console.log(`Detected: ${platformName} ${archName}`);
|
||||
console.log(`Backend: ${BACKEND}`);
|
||||
console.log(`Downloading: ${zipFilename}`);
|
||||
|
||||
// Create libs directory
|
||||
if (!fs.existsSync(LIBS_DIR)) {
|
||||
fs.mkdirSync(LIBS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Download zip with error handling
|
||||
await downloadFile(ZIP_URL, ZIP_PATH);
|
||||
|
||||
// Extract to temporary directory
|
||||
console.log('Extracting to temporary directory...');
|
||||
if (!fs.existsSync(TEMP_EXTRACT_DIR)) {
|
||||
fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true });
|
||||
}
|
||||
await extract(ZIP_PATH, { dir: TEMP_EXTRACT_DIR });
|
||||
|
||||
// Move libs folder contents to final location
|
||||
console.log('Moving libraries to libs/...');
|
||||
const libsSourcePath = path.join(TEMP_EXTRACT_DIR, 'libs');
|
||||
|
||||
if (fs.existsSync(libsSourcePath)) {
|
||||
// Copy all files from libs folder to LIBS_DIR
|
||||
const files = fs.readdirSync(libsSourcePath);
|
||||
files.forEach(file => {
|
||||
const src = path.join(libsSourcePath, file);
|
||||
const dest = path.join(LIBS_DIR, file);
|
||||
|
||||
if (fs.statSync(src).isDirectory()) {
|
||||
copyDirSync(src, dest);
|
||||
} else {
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('libs folder not found in zip archive');
|
||||
}
|
||||
|
||||
// Write installed.txt with version
|
||||
fs.writeFileSync(INSTALLED_FILE, `${RELEASE_TAG}:${BACKEND}`, 'utf-8');
|
||||
console.log(`✓ Wrote version ${RELEASE_TAG}:${BACKEND} to installed.txt`);
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true });
|
||||
fs.unlinkSync(ZIP_PATH);
|
||||
console.log('✓ Installation complete');
|
||||
} catch (err) {
|
||||
console.error('✗ Failed:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to recursively copy directories
|
||||
function copyDirSync(src, dest) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
const files = fs.readdirSync(src);
|
||||
files.forEach(file => {
|
||||
const srcFile = path.join(src, file);
|
||||
const destFile = path.join(dest, file);
|
||||
if (fs.statSync(srcFile).isDirectory()) {
|
||||
copyDirSync(srcFile, destFile);
|
||||
} else {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
https.get(url, { headers: { 'User-Agent': 'Node.js' } }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
file.destroy();
|
||||
fs.unlink(dest, () => {});
|
||||
return downloadFile(response.headers.location, dest)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}
|
||||
|
||||
// Handle 404
|
||||
if (response.statusCode === 404) {
|
||||
file.destroy();
|
||||
fs.unlink(dest, () => {});
|
||||
reject(new Error(
|
||||
`Release artifact not found (404). Check:\n` +
|
||||
` - Repository exists: ${url.split('/releases')[0]}\n` +
|
||||
` - Release tag exists: ${RELEASE_TAG}\n` +
|
||||
` - Artifact filename is correct`
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle 403
|
||||
if (response.statusCode === 403) {
|
||||
file.destroy();
|
||||
fs.unlink(dest, () => {});
|
||||
reject(new Error(
|
||||
`Access denied (403). The repository may be private.\n` +
|
||||
`Set GITHUB_TOKEN environment variable for private repos.`
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other HTTP errors
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
file.destroy();
|
||||
fs.unlink(dest, () => {});
|
||||
reject(new Error(
|
||||
`HTTP ${response.statusCode}: Failed to download from ${url}`
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
file.on('error', (err) => {
|
||||
fs.unlink(dest, () => {});
|
||||
reject(new Error(`File write error: ${err.message}`));
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
file.destroy();
|
||||
fs.unlink(dest, () => {});
|
||||
reject(new Error(`Download error: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
install();
|
||||
@@ -0,0 +1,202 @@
|
||||
import * as crypto from "crypto"
|
||||
import * as fs from "fs"
|
||||
import * as http from "http"
|
||||
import * as https from "https"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {pipeline} from "stream/promises"
|
||||
import extract = require("extract-zip")
|
||||
import type {Backend} from "./core"
|
||||
import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release"
|
||||
|
||||
const GITHUB_REPO = "simplex-chat/simplex-chat-libs"
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
export const MAX_REDIRECTS = 5
|
||||
const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = {
|
||||
linux: {name: "linux", lib: "libsimplex.so"},
|
||||
darwin: {name: "macos", lib: "libsimplex.dylib"},
|
||||
win32: {name: "windows", lib: "libsimplex.dll"},
|
||||
}
|
||||
const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"}
|
||||
const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"]
|
||||
|
||||
function unsupported(platform: string, arch: string): Error {
|
||||
return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`)
|
||||
}
|
||||
|
||||
export function platformTag(platform: string, arch: string): string {
|
||||
const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}`
|
||||
if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch)
|
||||
return tag
|
||||
}
|
||||
|
||||
function libName(platform: string = process.platform, arch: string = process.arch): string {
|
||||
const lib = PLATFORMS[platform]?.lib
|
||||
if (!lib) throw unsupported(platform, arch)
|
||||
return lib
|
||||
}
|
||||
|
||||
export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string {
|
||||
if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat")
|
||||
if (platform === "win32") {
|
||||
if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set")
|
||||
return path.join(env.LOCALAPPDATA, "simplex-chat")
|
||||
}
|
||||
return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat")
|
||||
}
|
||||
|
||||
export function libsAsset(backend: Backend, tag: string): string {
|
||||
const suffix = backend === "postgres" ? "-postgres" : ""
|
||||
return `simplex-chat-libs-${tag}${suffix}.zip`
|
||||
}
|
||||
|
||||
export function addonAsset(tag: string): string {
|
||||
return `simplex-chat-nodejs-${tag}.node`
|
||||
}
|
||||
|
||||
export function releaseUrl(release: string, asset: string): string {
|
||||
return `https://github.com/${GITHUB_REPO}/releases/download/${release}/${asset}`
|
||||
}
|
||||
|
||||
function pinnedSha256(asset: string): string {
|
||||
const sha256 = SHA256[asset]
|
||||
if (!sha256) throw new Error(`no SHA-256 is pinned for ${asset}`)
|
||||
return sha256
|
||||
}
|
||||
|
||||
export function libPath(dir: string): string {
|
||||
return path.join(dir, libName())
|
||||
}
|
||||
|
||||
export async function resolveLibsDir(
|
||||
backend: Backend,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch
|
||||
): Promise<string> {
|
||||
const lib = libName(platform, arch)
|
||||
if (env.SIMPLEX_LIBS_DIR) {
|
||||
const dir = path.resolve(env.SIMPLEX_LIBS_DIR)
|
||||
if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`)
|
||||
return dir
|
||||
}
|
||||
const tag = platformTag(platform, arch)
|
||||
if (backend === "postgres" && tag !== "linux-x86_64") {
|
||||
throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`)
|
||||
}
|
||||
const target = path.resolve(cacheRoot(platform, env), LIBS_RELEASE, backend)
|
||||
if (!fs.existsSync(path.join(target, lib))) {
|
||||
const asset = libsAsset(backend, tag)
|
||||
await installLibs(releaseUrl(LIBS_RELEASE, asset), pinnedSha256(asset), target, lib)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export async function resolveAddonPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch
|
||||
): Promise<string> {
|
||||
if (env.SIMPLEX_ADDON_PATH) {
|
||||
const file = path.resolve(env.SIMPLEX_ADDON_PATH)
|
||||
if (!fs.existsSync(file)) throw new Error(`SIMPLEX_ADDON_PATH does not exist: ${file}`)
|
||||
return file
|
||||
}
|
||||
if (!ADDON_RELEASE) throw new Error("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH")
|
||||
const asset = addonAsset(platformTag(platform, arch))
|
||||
const target = path.resolve(cacheRoot(platform, env), ADDON_RELEASE, asset)
|
||||
if (!fs.existsSync(target)) await installAddon(releaseUrl(ADDON_RELEASE, asset), pinnedSha256(asset), target)
|
||||
return target
|
||||
}
|
||||
|
||||
export function installLibs(url: string, sha256: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<void> {
|
||||
return inTempDir(target, async tmp => {
|
||||
console.error(`Downloading libsimplex from ${url} ...`)
|
||||
const zipPath = path.join(tmp, "libs.zip")
|
||||
await download(url, zipPath, timeoutMs)
|
||||
await verifySha256(zipPath, sha256, url)
|
||||
await extract(zipPath, {dir: tmp})
|
||||
const extracted = path.join(tmp, "libs")
|
||||
if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`)
|
||||
await renameInto(extracted, target, path.join(target, lib))
|
||||
})
|
||||
}
|
||||
|
||||
export function installAddon(url: string, sha256: string, target: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<void> {
|
||||
return inTempDir(target, async tmp => {
|
||||
console.error(`Downloading the Node.js add-on from ${url} ...`)
|
||||
const file = path.join(tmp, path.basename(target))
|
||||
await download(url, file, timeoutMs)
|
||||
await verifySha256(file, sha256, url)
|
||||
await renameInto(file, target, target)
|
||||
})
|
||||
}
|
||||
|
||||
async function verifySha256(file: string, expected: string, url: string): Promise<void> {
|
||||
const hash = crypto.createHash("sha256")
|
||||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
|
||||
const actual = hash.digest("hex")
|
||||
if (actual !== expected) throw new Error(`SHA-256 of ${url} is ${actual}, expected ${expected}`)
|
||||
}
|
||||
|
||||
// The temp dir is next to the target, so the final rename stays on one filesystem.
|
||||
async function inTempDir(target: string, install: (tmp: string) => Promise<void>): Promise<void> {
|
||||
const parent = path.dirname(target)
|
||||
await fs.promises.mkdir(parent, {recursive: true})
|
||||
const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-"))
|
||||
try {
|
||||
await install(tmp)
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, {recursive: true, force: true})
|
||||
}
|
||||
}
|
||||
|
||||
async function renameInto(from: string, target: string, installed: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.rename(from, target)
|
||||
} catch (e) {
|
||||
// Another process installed the same version first; its files are identical.
|
||||
// Windows reports renaming onto an existing directory or a loaded file as EPERM.
|
||||
const code = (e as NodeJS.ErrnoException).code
|
||||
const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target)
|
||||
if (!lost) throw e
|
||||
if (!fs.existsSync(installed)) {
|
||||
throw new Error(`another process partially populated ${target} but ${path.basename(installed)} is missing; remove it manually and retry`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = url.startsWith("https:") ? https.get : http.get
|
||||
// Once a response arrives, request errors are ignored: a download settles via pipeline, which
|
||||
// waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows.
|
||||
let res: http.IncomingMessage | undefined
|
||||
const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => {
|
||||
res = response
|
||||
const status = response.statusCode ?? 0
|
||||
const location = response.headers.location
|
||||
if (status >= 300 && status < 400 && location) {
|
||||
response.resume()
|
||||
if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`))
|
||||
let next: URL
|
||||
try {
|
||||
next = new URL(location, url)
|
||||
} catch (e) {
|
||||
return reject(e)
|
||||
}
|
||||
if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`))
|
||||
download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (status !== 200) {
|
||||
response.resume()
|
||||
return reject(new Error(`HTTP ${status} downloading ${url}`))
|
||||
}
|
||||
pipeline(response, fs.createWriteStream(dest)).then(resolve, reject)
|
||||
})
|
||||
const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err))
|
||||
req.on("timeout", () => abort(new Error(`timeout downloading ${url}`)))
|
||||
req.on("error", e => { if (!res) reject(e) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Written by scripts/ci/packages_release.py from the simplex-chat-libs releases.
|
||||
|
||||
export const LIBS_RELEASE = "core-7.1.0.11"
|
||||
|
||||
export const ADDON_RELEASE: string | undefined = undefined
|
||||
|
||||
export const SHA256: {readonly [asset: string]: string} = {
|
||||
"simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260",
|
||||
"simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803",
|
||||
"simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a",
|
||||
"simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61",
|
||||
"simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec",
|
||||
"simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b",
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// These functions are defined in CPP add-on ../cpp/simplex.cc
|
||||
|
||||
export function load(addonPath: string, libPath: string): void
|
||||
export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]>
|
||||
export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]>
|
||||
export function chat_close_store(ctrl: bigint): Promise<string>
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
module.exports = require("../build/Release/simplex")
|
||||
// The add-on is downloaded from the libsimplex release, so it is loaded at runtime by core.loadLibrary.
|
||||
const NOT_LOADED = "libsimplex is not loaded, call core.loadLibrary(backend) first"
|
||||
const BINDINGS = ["chat_migrate_init", "chat_migrate_init_queue", "chat_close_store", "chat_send_cmd", "chat_recv_msg_wait",
|
||||
"chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file"]
|
||||
|
||||
let addon
|
||||
|
||||
exports.load = (addonPath, libPath) => {
|
||||
if (!addon) {
|
||||
const addonModule = {exports: {}}
|
||||
process.dlopen(addonModule, addonPath)
|
||||
addon = addonModule.exports
|
||||
}
|
||||
addon.load(libPath)
|
||||
}
|
||||
|
||||
for (const name of BINDINGS) {
|
||||
exports[name] = (...args) => {
|
||||
if (!addon) throw new Error(NOT_LOADED)
|
||||
return addon[name](...args)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user