Skip to content

Commit 9814c30

Browse files
committed
Add JSR package support for Linux shared library
- Introduced a new GitHub Actions workflow for publishing the native Linux shared library as a JSON/base64 blob to JSR. - Added scripts for embedding the shared library and generating the corresponding JSON metadata. - Created TypeScript bindings for the shared library, enhancing usability and type safety. - Updated CMake configuration to generate JSR package metadata during the build process. - Included a README for usage instructions and permissions required for the library.
1 parent 8d1ed1e commit 9814c30

9 files changed

Lines changed: 358 additions & 0 deletions

File tree

.github/workflows/publish-jsr.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Publish to JSR (@serial/cpp-bindings-linux)
2+
3+
on:
4+
workflow_dispatch:
5+
inputs: {}
6+
push:
7+
tags:
8+
- "v*"
9+
10+
jobs:
11+
publish-jsr:
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Install build dependencies
21+
env:
22+
DEBIAN_FRONTEND: noninteractive
23+
run: |
24+
sudo apt-get update
25+
sudo apt-get install -y --no-install-recommends ninja-build g++ git ca-certificates
26+
27+
- name: Setup CMake >= 3.30
28+
uses: jwlawson/actions-setup-cmake@v2
29+
with:
30+
cmake-version: "3.31.x"
31+
32+
- name: Setup Deno
33+
uses: denoland/setup-deno@v2
34+
with:
35+
deno-version: "2.6.0"
36+
37+
- name: Configure CMake (Release)
38+
env:
39+
CXX: g++
40+
run: |
41+
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
42+
43+
- name: Build shared library
44+
run: |
45+
cmake --build build --config Release
46+
47+
- name: Embed .so as JSON/base64 for JSR
48+
run: |
49+
deno run --allow-read --allow-write jsr/scripts/embed_so.ts \
50+
build/libcpp_bindings_linux.so \
51+
jsr/binaries/linux-x86_64.json \
52+
linux-x86_64
53+
54+
- name: Publish package to JSR
55+
working-directory: jsr
56+
run: |
57+
deno publish
58+
59+

CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ project(
2323
LANGUAGES CXX
2424
)
2525

26+
# Generate JSR package metadata from the same git-derived version as the library.
27+
# We generate into the build directory to avoid touching tracked files during normal local builds.
28+
configure_file(
29+
"${CMAKE_SOURCE_DIR}/jsr/jsr.json.in"
30+
"${CMAKE_SOURCE_DIR}/jsr/jsr.json"
31+
@ONLY
32+
)
33+
2634
# Set C++ standard
2735
set(CMAKE_CXX_STANDARD 23)
2836
set(CMAKE_CXX_STANDARD_REQUIRED ON)

jsr/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
jsr.json

jsr/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# @serial/cpp-bindings-linux
2+
3+
JSR package that ships the native Linux shared library
4+
(`libcpp_bindings_linux.so`) as a **JSON/base64 blob** and reconstructs it
5+
on-demand, because JSR currently doesn't handle binaries as first-class
6+
artifacts.
7+
8+
## Usage
9+
10+
```ts
11+
import { createErrorCallback, loadSerialLib } from "@serial/cpp-bindings-linux";
12+
13+
const { pointer: errPtr, close: closeErr } = createErrorCallback(
14+
(code, msg) => {
15+
console.error("native error", code, msg);
16+
},
17+
);
18+
19+
const lib = await loadSerialLib();
20+
// lib.symbols.serialOpen(...) etc.
21+
22+
// cleanup
23+
closeErr();
24+
lib.close();
25+
```
26+
27+
## Permissions
28+
29+
- `--allow-ffi`
30+
- `--allow-read`
31+
- `--allow-write` (only needed if you use the embedded binary extraction path)

jsr/binaries/linux-x86_64.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"target": "linux-x86_64",
3+
"filename": "libcpp_bindings_linux.so",
4+
"encoding": "base64",
5+
"sha256": "",
6+
"data": ""
7+
}
8+
9+

jsr/ffi.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export const symbols = {
2+
serialOpen: {
3+
parameters: ["pointer", "i32", "i32", "i32", "i32", "pointer"] as const,
4+
result: "i64" as const,
5+
},
6+
serialClose: {
7+
parameters: ["i64", "pointer"] as const,
8+
result: "i32" as const,
9+
},
10+
serialRead: {
11+
parameters: ["i64", "pointer", "i32", "i32", "i32", "pointer"] as const,
12+
result: "i32" as const,
13+
},
14+
serialWrite: {
15+
parameters: ["i64", "pointer", "i32", "i32", "i32", "pointer"] as const,
16+
result: "i32" as const,
17+
},
18+
};
19+
20+
export type LoadedLibrary = Deno.DynamicLibrary<typeof symbols>;
21+
export type SerialLib = LoadedLibrary["symbols"];
22+
23+
export type ErrorCallback = (code: number, message: string) => void;
24+
25+
/**
26+
* Creates a C-callable callback pointer compatible with `ErrorCallbackT` in the .so
27+
* (signature: `void (*)(int code, const char* message)`).
28+
*
29+
* Remember to `close()` it when you're done.
30+
*/
31+
export function createErrorCallback(cb: ErrorCallback): {
32+
pointer: Deno.PointerValue;
33+
close: () => void;
34+
} {
35+
const callback = new Deno.UnsafeCallback(
36+
{ parameters: ["i32", "pointer"], result: "void" } as const,
37+
(code: number, messagePtr: Deno.PointerValue) => {
38+
let message = "";
39+
try {
40+
if (messagePtr) {
41+
message = new Deno.UnsafePointerView(messagePtr)
42+
.getCString();
43+
}
44+
} catch {
45+
// best-effort: ignore malformed pointers
46+
}
47+
cb(code, message);
48+
},
49+
);
50+
51+
return {
52+
pointer: callback.pointer,
53+
close: () => callback.close(),
54+
};
55+
}

jsr/jsr.json.in

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@serial/cpp-bindings-linux",
3+
"version": "@PROJECT_VERSION@",
4+
"description": "Linux shared-library bindings for Serial-IO/cpp-core (distributed via JSON/base64 because JSR has limited binary support).",
5+
"exports": {
6+
".": "./mod.ts"
7+
},
8+
"publish": {
9+
"include": [
10+
"README.md",
11+
"jsr.json",
12+
"mod.ts",
13+
"ffi.ts",
14+
"binaries/**"
15+
],
16+
"exclude": [
17+
"scripts/**"
18+
]
19+
}
20+
}
21+
22+

jsr/mod.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {
2+
createErrorCallback,
3+
type LoadedLibrary,
4+
type SerialLib,
5+
symbols,
6+
} from "./ffi.ts";
7+
8+
type EmbeddedBinary = {
9+
target: string;
10+
filename: string;
11+
encoding: "base64";
12+
sha256?: string;
13+
data: string;
14+
};
15+
16+
let extractedLibraryPath: string | null = null;
17+
18+
function base64ToBytes(base64: string): Uint8Array {
19+
const bin = atob(base64);
20+
const out = new Uint8Array(bin.length);
21+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
22+
return out;
23+
}
24+
25+
async function loadEmbeddedBinary(): Promise<EmbeddedBinary> {
26+
const os = Deno.build.os;
27+
const arch = Deno.build.arch;
28+
const target = `${os}-${arch}`;
29+
30+
if (target !== "linux-x86_64") {
31+
throw new Error(
32+
`@serial/cpp-bindings-linux only ships an embedded binary for linux-x86_64 right now (got ${target}).`,
33+
);
34+
}
35+
36+
// Keep this as a dynamic import so local dev builds don't require a real binary JSON.
37+
const mod = await import("./binaries/linux-x86_64.json", {
38+
with: { type: "json" },
39+
});
40+
return mod.default as EmbeddedBinary;
41+
}
42+
43+
export type EnsureLibraryOptions = {
44+
/**
45+
* Directory to write the extracted .so into.
46+
* Defaults to a temporary directory.
47+
*/
48+
dir?: string;
49+
/**
50+
* If true, re-write the file even if we already extracted it in this process.
51+
*/
52+
force?: boolean;
53+
};
54+
55+
/**
56+
* Extract the embedded `.so` (stored as JSON/base64) to a real file on disk and return its path.
57+
*
58+
* Required permissions:
59+
* - `--allow-read` (to read the embedded JSON in the package)
60+
* - `--allow-write` (to write the `.so` to disk)
61+
*/
62+
export async function ensureSharedLibraryFile(
63+
options: EnsureLibraryOptions = {},
64+
): Promise<string> {
65+
if (extractedLibraryPath && !options.force) return extractedLibraryPath;
66+
67+
const embedded = await loadEmbeddedBinary();
68+
if (!embedded.data) {
69+
throw new Error(
70+
"Embedded binary JSON is empty. If you're running from source, generate it via jsr/scripts/embed_so.ts.",
71+
);
72+
}
73+
74+
const dir = options.dir ??
75+
await Deno.makeTempDir({ prefix: "serial-cpp-bindings-linux-" });
76+
const path = `${dir}/${embedded.filename}`;
77+
const bytes = base64ToBytes(embedded.data);
78+
79+
await Deno.writeFile(path, bytes, { mode: 0o755 });
80+
extractedLibraryPath = path;
81+
return path;
82+
}
83+
84+
export type LoadSerialLibOptions = {
85+
/**
86+
* If provided, skips extraction and loads the .so from this path.
87+
*/
88+
libraryPath?: string;
89+
/**
90+
* Directory to write the extracted .so into (if `libraryPath` is not provided).
91+
*/
92+
extractDir?: string;
93+
/**
94+
* Force re-extract (useful if you manage the directory yourself).
95+
*/
96+
forceExtract?: boolean;
97+
};
98+
99+
/**
100+
* Load the native library via `Deno.dlopen`.
101+
*
102+
* Required permissions:
103+
* - `--allow-ffi`
104+
* - `--allow-read` (to load the .so)
105+
* - `--allow-write` (only if extracting the embedded binary)
106+
*/
107+
export async function loadSerialLib(
108+
options: LoadSerialLibOptions = {},
109+
): Promise<LoadedLibrary> {
110+
await Promise.resolve(); // keep async for API stability
111+
112+
const path = options.libraryPath ??
113+
await ensureSharedLibraryFile({
114+
dir: options.extractDir,
115+
force: options.forceExtract,
116+
});
117+
118+
return Deno.dlopen(path, symbols) as LoadedLibrary;
119+
}
120+
121+
export { createErrorCallback, symbols };
122+
export type { LoadedLibrary, SerialLib };

jsr/scripts/embed_so.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Usage:
2+
// deno run --allow-read --allow-write jsr/scripts/embed_so.ts \
3+
// ./build/libcpp_bindings_linux.so ./jsr/binaries/linux-x86_64.json linux-x86_64
4+
//
5+
// This converts the shared library into a JSON file containing base64 data for publishing to JSR.
6+
7+
function bytesToHex(bytes: Uint8Array): string {
8+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
9+
}
10+
11+
function base64FromBytes(bytes: Uint8Array): string {
12+
// Chunk to avoid call stack limits in String.fromCharCode(...bigArray)
13+
const chunkSize = 0x8000;
14+
let binary = "";
15+
for (let i = 0; i < bytes.length; i += chunkSize) {
16+
const chunk = bytes.subarray(i, i + chunkSize);
17+
binary += String.fromCharCode(...chunk);
18+
}
19+
return btoa(binary);
20+
}
21+
22+
if (import.meta.main) {
23+
const [inPath, outPath, target = "linux-x86_64"] = Deno.args;
24+
if (!inPath || !outPath) {
25+
console.error(
26+
"Expected: <input .so path> <output .json path> [target]\nExample: build/libcpp_bindings_linux.so jsr/binaries/linux-x86_64.json linux-x86_64",
27+
);
28+
Deno.exit(2);
29+
}
30+
31+
const bytes = await Deno.readFile(inPath);
32+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
33+
const sha256 = bytesToHex(digest);
34+
35+
const filename = outPath.endsWith(".json")
36+
? (target === "linux-x86_64"
37+
? "libcpp_bindings_linux.so"
38+
: "libcpp_bindings_linux.so")
39+
: "libcpp_bindings_linux.so";
40+
41+
const payload = {
42+
target,
43+
filename,
44+
encoding: "base64" as const,
45+
sha256,
46+
data: base64FromBytes(bytes),
47+
};
48+
49+
await Deno.writeTextFile(outPath, JSON.stringify(payload));
50+
console.log(`Wrote ${outPath} (${bytes.length} bytes, sha256=${sha256})`);
51+
}

0 commit comments

Comments
 (0)