clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat: JSR publish pipeline for @nzip/lofi-node v0.1.0

Dami committed 2855974
The JSR package ships TypeScript only (~70KB; publish include/exclude
keeps the 51MB native prebuilts out). JSR consumers get the native layer
from GitHub-release assets: src/native/artifacts.ts (generated by
deno task release:artifacts, freshness-gated in CI) pins release asset
names + sha256 digests; the loader gains a download-verify-cache step
when repo-relative resolution misses (typed MeshUnavailableError on
offline/checksum-mismatch; Windows keeps its documented degradation).
loadIrohAddon is async now.

publish.yml (tag v*.*.*): tag↔version check, artifacts freshness, full
gate + suite with cargo-built addon, publish dry-run, deno publish via
OIDC, three cross-compiled binaries (darwin-arm64, linux-x64/arm64 —
per-triple embeds), gh release with binaries + raw addons + SHA256SUMS,
then a fresh-publish registry smoke (CLI run, library import, deno x
subpath probe).

Docs: README quick start is JSR-first (dx / deno run / install -g /
release binaries incl. unsigned-macOS note); CONTRIBUTING gains the
release procedure (tags on main, fix-forward); hosting walkthrough
install step updated. New artifacts-manifest integrity test; 35 green.
commit
2855974faa29a11c5ce3eec8faaac72cc94843bb
parent
82ae44fac7f99f33a1355c1d4b7bcbe2075901a0
author
Dami <dami.etoile@gmail.com>
10 files changed, +313 -41
A.github/workflows/publish.yml99++++++++++++++++++++++++++++++
MCONTRIBUTING.md21+++++--
MREADME.md29+++++++--
Mdeno.json25+++++++-
Mdocs/hosting-lofi-apps.md3+
Msrc/native/addon.ts88++++++++++++++++++++++-----
Asrc/native/artifacts.ts22+++++++
Msrc/node.ts2+-
Mtests/tunnel_iroh_test.ts29+++++----
Atools/release_artifacts.ts36+++++++++++
A .github/workflows/publish.yml +99 -0
@@ -0,0 +1,99 @@
1+# Tag a release (e.g. `git tag v0.1.0 && git push origin v0.1.0`) to publish2+# @nzip/lofi-node to JSR and attach compiled binaries + native addon artifacts3+# to the GitHub release. The tag version must match "version" in deno.json.4+name: publish5+6+on:7+ push:8+ tags: ["v*.*.*"]9+10+permissions:11+ contents: write # gh release create + asset upload12+ id-token: write # JSR OIDC publish13+14+jobs:15+ publish:16+ runs-on: ubuntu-latest17+ steps:18+ - uses: actions/checkout@v419+20+ - uses: denoland/setup-deno@v221+ with:22+ deno-version: v2.x23+24+ - name: Tag must match deno.json version25+ run: |26+ tag="${GITHUB_REF_NAME#v}"27+ manifest="$(deno eval 'console.log(JSON.parse(Deno.readTextFileSync("deno.json")).version)')"28+ if [ "$tag" != "$manifest" ]; then29+ echo "::error::tag v$tag does not match deno.json version $manifest"30+ exit 131+ fi32+33+ - name: Release artifacts manifest must be fresh34+ run: |35+ deno task release:artifacts36+ git diff --exit-code src/native/artifacts.ts37+38+ - name: Format, lint, typecheck39+ run: deno task check40+41+ - uses: dtolnay/rust-toolchain@stable42+43+ - uses: Swatinem/rust-cache@v244+ with:45+ workspaces: native/iroh-js46+47+ - name: Build native addon48+ run: deno task native49+50+ - name: Full test suite51+ run: deno task test52+53+ - name: Publish dry run54+ run: deno publish --dry-run --allow-dirty55+56+ - name: Publish to JSR57+ run: deno publish58+59+ - name: Compile release binaries (3 targets)60+ run: |61+ deno task compile:darwin-arm6462+ deno task compile:linux-x6463+ deno task compile:linux-arm6464+65+ - name: Stage release assets66+ run: |67+ mkdir -p dist/assets68+ cp dist/lofi-node-aarch64-apple-darwin dist/assets/69+ cp dist/lofi-node-x86_64-unknown-linux-gnu dist/assets/70+ cp dist/lofi-node-aarch64-unknown-linux-gnu dist/assets/71+ # Raw addon artifacts under the names artifacts.ts pins.72+ cp native/iroh-js/prebuilt/aarch64-apple-darwin/libnumber0_iroh.dylib \73+ dist/assets/libnumber0_iroh-aarch64-apple-darwin.dylib74+ cp native/iroh-js/prebuilt/x86_64-unknown-linux-gnu/libnumber0_iroh.so \75+ dist/assets/libnumber0_iroh-x86_64-unknown-linux-gnu.so76+ cp native/iroh-js/prebuilt/aarch64-unknown-linux-gnu/libnumber0_iroh.so \77+ dist/assets/libnumber0_iroh-aarch64-unknown-linux-gnu.so78+ (cd dist/assets && sha256sum * > SHA256SUMS)79+ cat dist/assets/SHA256SUMS80+81+ - name: Create GitHub release with assets82+ env:83+ GH_TOKEN: ${{ github.token }}84+ run: |85+ gh release create "$GITHUB_REF_NAME" dist/assets/* \86+ --title "$GITHUB_REF_NAME" --generate-notes87+88+ - name: Registry smoke — CLI, library, and dx from a clean dir89+ run: |90+ version="${GITHUB_REF_NAME#v}"91+ smoke="$(mktemp -d)"92+ cd "$smoke"93+ for i in $(seq 1 20); do94+ deno run -A "jsr:@nzip/lofi-node@$version/cli" >/dev/null 2>&1 && break95+ echo "waiting for registry propagation ($i)"; sleep 1596+ done97+ deno run -A "jsr:@nzip/lofi-node@$version/cli" | head -398+ deno eval "import { looksLikeAppTicket } from 'jsr:@nzip/lofi-node@$version'; if (!looksLikeAppTicket('lofisync1.abcdef')) throw new Error('lib smoke failed'); console.log('library import ok')"99+ deno x -A "jsr:@nzip/lofi-node@$version/cli" && echo "deno x subpath form works" || echo "::warning::deno x subpath form failed — README documents deno run/install fallbacks"
M CONTRIBUTING.md +16 -5
M README.md +24 -5
@@ -13,12 +13,31 @@ dependency.
1313 1414 ## Quick start1515 16+Run straight from JSR with `dx` (Deno's npx — `deno x`):17+18+```sh19+dx -A jsr:@nzip/lofi-node/cli init --port 4802 # ticket-gated by default; --open opts out20+dx -A jsr:@nzip/lofi-node/cli start # gate URL + node-pairing ticket21+dx -A jsr:@nzip/lofi-node/cli ticket issue --label phone # app-connect ticket (shown once)22+```23+24+Equivalent forms:25+26+```sh27+deno run -A jsr:@nzip/lofi-node/cli start # plain deno28+deno install -g -A -n lofi-node jsr:@nzip/lofi-node/cli # persistent `lofi-node` command29+```30+31+On first start the native iroh layer is downloaded from this repo's GitHub release for the matching32+version and sha256-verified against digests pinned inside the package. No Deno at all? Grab a33+compiled binary from the [releases](https://github.com/FelineStateMachine/lofi-node/releases) (macOS34+arm64, Linux x86_64/aarch64; the macOS binary is unsigned — clear quarantine with35+`xattr -d com.apple.quarantine lofi-node-*`).36+37+As a library:38+1639 ```sh17-deno task compile # one self-contained binary (dist/lofi-node)18-dist/lofi-node init --port 4802 # ticket-gated by default; --open opts out19-dist/lofi-node start # gate URL + node-pairing ticket20-dist/lofi-node ticket issue --label phone # app-connect ticket (shown once)21-dist/lofi-node pair endpoint… # elect another node as upstream40+deno add jsr:@nzip/lofi-node2241 ```2342 2443 The issued `lofisync1.…` ticket carries **location + secret**; the lofi app stores it
M deno.json +24 -1
M docs/hosting-lofi-apps.md +3 -0
M src/native/addon.ts +71 -17
@@ -8,6 +8,7 @@
88 import { createRequire } from "node:module";99 import { MeshUnavailableError } from "../errors.ts";1010 import { resolveIrohLib } from "./loader.ts";11+import { RELEASE_ARTIFACTS } from "./artifacts.ts";1112 1213 export interface SecretKey {1314 toBytes(): number[];
@@ -156,29 +157,82 @@ function stageNodeArtifact(srcPath: string): string {
156157 return target;157158 }158159 159-/** Load + probe the addon. Throws MeshUnavailableError with a precise reason160- * (no silent degradation — lofi's boot-gate ethos). */161-export function loadIrohAddon(explicitPath?: string): IrohAddon {162- const resolved = resolveIrohLib(explicitPath);163- if (resolved.status === "unsupported-platform") {164- throw new MeshUnavailableError(`no iroh-js build for ${resolved.platform}`);160+/** Fetch the platform's addon from the GitHub release pinned by the161+ * committed artifacts.ts, verify its sha256 against the in-package digest162+ * (the JSR artifact pins the binaries it will accept), and stage it into the163+ * version-keyed OS cache. This is how JSR consumers get the native layer —164+ * the package itself ships no binaries. */165+async function downloadReleaseArtifact(platform: string): Promise<string> {166+ const asset = (RELEASE_ARTIFACTS.assets as Record<string, { file: string; sha256: string }>)[167+ platform168+ ];169+ if (!asset) {170+ throw new MeshUnavailableError(`no release artifact for ${platform}`);165171 }166- if (resolved.status === "not-found") {172+ const dir = `${osCacheDir()}/lofi-node/iroh-js-release-${RELEASE_ARTIFACTS.version}-${platform}`;173+ const target = `${dir}/iroh.node`;174+ try {175+ Deno.statSync(target);176+ return target; // version-keyed: verified at download time177+ } catch {178+ // download below179+ }180+ const url =181+ `https://github.com/FelineStateMachine/lofi-node/releases/download/v${RELEASE_ARTIFACTS.version}/${asset.file}`;182+ let bytes: Uint8Array;183+ try {184+ const res = await fetch(url);185+ if (!res.ok) {186+ await res.body?.cancel();187+ throw new Error(`HTTP ${res.status}`);188+ }189+ bytes = new Uint8Array(await res.arrayBuffer());190+ } catch (e) {167191 throw new MeshUnavailableError(168- `iroh-js addon not found; tried: ${resolved.tried.join(", ")} — build with ` +169- `\`cargo build --release\` in native/iroh-js, or set LOFI_NODE_IROH`,192+ `could not download native addon from ${url}: ${(e as Error).message}`,170193 );171194 }195+ const digest = await crypto.subtle.digest("SHA-256", bytes.buffer as ArrayBuffer);196+ const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");197+ if (sha256 !== asset.sha256) {198+ throw new MeshUnavailableError(199+ `native addon checksum mismatch for ${asset.file}: got ${sha256.slice(0, 16)}…, ` +200+ `expected ${asset.sha256.slice(0, 16)}… — refusing to load`,201+ );202+ }203+ Deno.mkdirSync(dir, { recursive: true });204+ const tmp = `${target}.${Deno.pid}.tmp`;205+ Deno.writeFileSync(tmp, bytes);206+ Deno.renameSync(tmp, target);207+ return target;208+}209+210+/** Load + probe the addon. Resolution: env/explicit → cargo build output →211+ * in-repo prebuilt → GitHub-release download (JSR consumers). Throws212+ * MeshUnavailableError with a precise reason (no silent degradation —213+ * lofi's boot-gate ethos). */214+export async function loadIrohAddon(explicitPath?: string): Promise<IrohAddon> {215+ const resolved = resolveIrohLib(explicitPath);216+ if (resolved.status === "unsupported-platform") {217+ throw new MeshUnavailableError(`no iroh-js build for ${resolved.platform}`);218+ }172219 // require() only loads native addons from `.node` files — cargo emits173220 // .dylib/.so/.dll, and a compiled binary's embedded artifacts aren't174221 // directly loadable at all; stage a real .node file either way.175222 let loadPath: string;176- try {177- loadPath = stageNodeArtifact(resolved.path);178- } catch (e) {179- throw new MeshUnavailableError(180- `could not stage .node artifact for ${resolved.path}: ${(e as Error).message}`,181- );223+ if (resolved.status === "not-found") {224+ // Running from the JSR cache (no repo checkout): fetch the pinned225+ // release artifact instead.226+ const platform = `${Deno.build.os}-${Deno.build.arch}`;227+ loadPath = await downloadReleaseArtifact(platform);228+ } else {229+ try {230+ loadPath = stageNodeArtifact(resolved.path);231+ } catch (e) {232+ throw new MeshUnavailableError(233+ `could not stage .node artifact for ${resolved.path}: ${(e as Error).message}`,234+ );235+ }182236 }183237 let addon: IrohAddon;184238 try {
@@ -189,11 +243,11 @@ export function loadIrohAddon(explicitPath?: string): IrohAddon {
189243 // Probe: core surface + the lofi_ext marker prove this is OUR build, not a190244 // stock upstream artifact.191245 if (typeof addon.Endpoint?.builder !== "function") {192- throw new MeshUnavailableError(`addon at ${resolved.path} lacks the Endpoint surface`);246+ throw new MeshUnavailableError(`addon at ${loadPath} lacks the Endpoint surface`);193247 }194248 if (typeof addon.maxFrame !== "function" || typeof addon.writeFrame !== "function") {195249 throw new MeshUnavailableError(196- `addon at ${resolved.path} lacks lofi_ext framing — built from stock upstream?`,250+ `addon at ${loadPath} lacks lofi_ext framing — built from stock upstream?`,197251 );198252 }199253 return addon;
A src/native/artifacts.ts +22 -0
M src/node.ts +1 -1
M tests/tunnel_iroh_test.ts +17 -12
A tools/release_artifacts.ts +36 -0
@@ -0,0 +1,36 @@
1+// Regenerates src/native/artifacts.ts from native/iroh-js/prebuilt/ and2+// deno.json: the release-asset names and sha256 digests the JSR-published3+// loader will accept when downloading the native addon from the GitHub4+// release. CI verifies the file is fresh (`git diff --exit-code`).5+//6+// Run: deno task release:artifacts7+8+const TRIPLES: Record<string, { dir: string; ext: string }> = {9+ "darwin-aarch64": { dir: "aarch64-apple-darwin", ext: "dylib" },10+ "linux-x86_64": { dir: "x86_64-unknown-linux-gnu", ext: "so" },11+ "linux-aarch64": { dir: "aarch64-unknown-linux-gnu", ext: "so" },12+};13+14+const version = JSON.parse(await Deno.readTextFile("deno.json")).version as string;15+16+const assets: Record<string, { file: string; sha256: string }> = {};17+for (const [platform, { dir, ext }] of Object.entries(TRIPLES)) {18+ const source = `native/iroh-js/prebuilt/${dir}/libnumber0_iroh.${ext}`;19+ const bytes = await Deno.readFile(source);20+ const digest = await crypto.subtle.digest("SHA-256", bytes);21+ const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");22+ assets[platform] = { file: `libnumber0_iroh-${dir}.${ext}`, sha256 };23+}24+25+const banner = `// GENERATED by tools/release_artifacts.ts — do not edit by hand.26+// Release-asset names + sha256 digests for the native addon; the loader27+// downloads these from the GitHub release for version ${version} and refuses28+// anything that does not match. Regenerate: deno task release:artifacts29+30+export const RELEASE_ARTIFACTS = ${JSON.stringify({ version, assets }, null, 2)} as const;31+`;32+await Deno.writeTextFile("src/native/artifacts.ts", banner);33+console.log(`src/native/artifacts.ts regenerated for v${version}`);34+for (const [platform, asset] of Object.entries(assets)) {35+ console.log(` ${platform}: ${asset.file} ${asset.sha256.slice(0, 16)}…`);36+}