Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules
dist
*.tsbuildinfo
*.tgz
*.tgz
.ipfs
.ipfs-test
43 changes: 28 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Install [Kubo](https://docs.ipfs.tech/install/) (`ipfs` on your PATH). Meshkit c
```bash
npm install
npm run build
npm run test:persistence # repo survive shutdown + restart
```

## Usage
Expand All @@ -40,40 +41,52 @@ npm install @ipfs-meshkit/meshkit

### Node.js — automatic local Kubo

`localNode: true` stores pinned data in `./.ipfs` (relative to where you start the process). Add `.ipfs` to `.gitignore`.

```typescript
import { readFile, writeFile } from 'node:fs/promises';
import { init, stopIPFSNode } from '@ipfs-meshkit/meshkit';
import { init, listPins, setupGracefulShutdown } from '@ipfs-meshkit/meshkit';

const { meshkit, localNode } = await init({ localNode: true });

setupGracefulShutdown(localNode); // Ctrl+C flushes Kubo; ./.ipfs stays on disk

const pdf = await readFile('./invoice.pdf');
const cid = await meshkit.upload(pdf);
await meshkit.pin(cid);

console.log('repo:', localNode?.repo);
console.log('pins:', await listPins(meshkit.activeNodes[0]!));

const retrieved = await meshkit.retrieve(cid);
await writeFile('./invoice-copy.pdf', retrieved);

if (localNode?.managed) {
await stopIPFSNode(localNode);
}
```

### Node.js — server bootstrap
### Migrating servers (AWS → GCP)

1. Stop the server gracefully (`setupGracefulShutdown` or `stopIPFSNode`)
2. Copy the `./.ipfs` directory (tar, EBS snapshot, S3, etc.)
3. Restore on the new host and start with the same repo path:

```typescript
import { startIPFSNode, stopIPFSNode, init } from '@ipfs-meshkit/meshkit';
const { meshkit, localNode } = await init({
localNode: { repo: './.ipfs', init: false },
});
```

Use `listPins()` to export CIDs as a backup manifest for re-pinning.

const kubo = await startIPFSNode();
const { meshkit } = await init({ nodes: [kubo.url] });
### Node.js — server bootstrap

// ... your HTTP server ...
```typescript
import { init, setupGracefulShutdown } from '@ipfs-meshkit/meshkit';

process.on('SIGTERM', async () => {
if (kubo.managed) {
await stopIPFSNode(kubo);
}
process.exit(0);
const { meshkit, localNode } = await init({ localNode: true });
setupGracefulShutdown(localNode, {
onShutdown: async () => { /* close HTTP server, DB, etc. */ },
});

// ... app.listen(3000) ...
```

`startIPFSNode` reuses an existing daemon on `127.0.0.1:5001` when one is already healthy.
Expand Down
2 changes: 2 additions & 0 deletions examples/dummy-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.ipfs
.ipfs-test
3 changes: 2 additions & 1 deletion examples/dummy-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
"start": "node server.js",
"test:persistence": "node --experimental-strip-types test-persistence.ts"
},
"dependencies": {
"@ipfs-meshkit/meshkit": "*"
Expand Down
19 changes: 11 additions & 8 deletions examples/dummy-app/server.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import { init, stopIPFSNode } from '@ipfs-meshkit/meshkit';
import { init, listPins, setupGracefulShutdown } from '@ipfs-meshkit/meshkit';

async function bootstrap() {
const { meshkit, localNode } = await init({ localNode: true });

setupGracefulShutdown(localNode);

console.log('Meshkit ready');
console.log(' repo:', localNode?.repo ?? '(attached to external daemon)');
console.log(' active nodes:', meshkit.activeNodes);
console.log(' kubo managed:', localNode?.managed ?? false);
console.log(' Ctrl+C stops Kubo gracefully — ./.ipfs data stays on disk');

const text = `hello from dummy-app @ ${new Date().toISOString()}`;
const cid = await meshkit.upload(new TextEncoder().encode(text));
console.log(' uploaded cid:', cid);
await meshkit.pin(cid);
console.log(' uploaded & pinned cid:', cid);

const pins = await listPins(meshkit.activeNodes[0]);
console.log(' pinned cids:', pins.length);

const retrieved = new TextDecoder().decode(await meshkit.retrieve(cid));
console.log(' retrieved:', retrieved);

if (localNode?.managed) {
await stopIPFSNode(localNode);
console.log(' kubo stopped');
}

console.log('dummy-app ok');
console.log('\nServer running — press Ctrl+C to shut down gracefully');
}

bootstrap().catch((err) => {
Expand Down
119 changes: 119 additions & 0 deletions examples/dummy-app/test-persistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { rm, access } from 'node:fs/promises';
import { join } from 'node:path';
import {
init,
listPins,
stopIPFSNode,
resolveRepoPath,
} from '@ipfs-meshkit/meshkit';

const TEST_REPO = '.ipfs-test';
const TEST_PORT = 15_001;
const TEST_GATEWAY_PORT = 18_001;
const TEST_HOST = '127.0.0.1';
const TEST_URL = `http://${TEST_HOST}:${TEST_PORT}`;
const repoPath = resolveRepoPath(TEST_REPO);

let passed = 0;
let failed = 0;

function assert(condition: boolean, message: string): void {
if (condition) {
console.log(` ✓ ${message}`);
passed += 1;
return;
}
console.error(` ✗ ${message}`);
failed += 1;
}

async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}

async function phaseOne(): Promise<{ cid: string; payload: string }> {
console.log('\n[phase 1] start, upload, pin, list pins, graceful stop');

const { meshkit, localNode } = await init({
localNode: {
repo: TEST_REPO,
host: TEST_HOST,
port: TEST_PORT,
gatewayPort: TEST_GATEWAY_PORT,
},
});

assert(localNode?.managed === true, 'spawned managed Kubo');
assert(localNode?.repo === repoPath, `repo path is ${repoPath}`);
assert(meshkit.activeNodes[0] === TEST_URL, 'connected to test node');

const payload = `persistence-test-${Date.now()}`;
const cid = await meshkit.upload(new TextEncoder().encode(payload));
await meshkit.pin(cid);

const pins = await listPins(localNode!.url);
assert(pins.includes(cid), `pin list contains ${cid}`);

if (localNode?.managed) {
await stopIPFSNode(localNode);
}

assert(await pathExists(join(repoPath, 'blocks')), 'repo blocks dir exists on disk after stop');

return { cid, payload };
}

async function phaseTwo(cid: string, originalPayload: string): Promise<void> {
console.log('\n[phase 2] restart same repo, verify data survived shutdown');

const { meshkit, localNode } = await init({
localNode: {
repo: TEST_REPO,
host: TEST_HOST,
port: TEST_PORT,
gatewayPort: TEST_GATEWAY_PORT,
init: false,
},
});

assert(localNode?.managed === true, 'respawned Kubo on same repo');

const retrieved = new TextDecoder().decode(await meshkit.retrieve(cid));
assert(retrieved === originalPayload, 'retrieved same bytes after restart');

const pins = await listPins(localNode!.url);
assert(pins.includes(cid), 'pin survived restart');

if (localNode?.managed) {
await stopIPFSNode(localNode);
}
}

async function main(): Promise<void> {
console.log('meshkit persistence integration test');

if (await pathExists(repoPath)) {
await rm(repoPath, { recursive: true, force: true });
}

const { cid, payload } = await phaseOne();
await phaseTwo(cid, payload);

await rm(repoPath, { recursive: true, force: true });

console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
console.log('all persistence tests ok');
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
],
"scripts": {
"build": "tsc --build",
"clean": "npm run clean --workspaces --if-present"
"clean": "npm run clean --workspaces --if-present",
"test:persistence": "npm run test:persistence --workspace=meshkit-dummy-app"
},
"repository": {
"type": "git",
Expand Down
3 changes: 3 additions & 0 deletions packages/meshkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"dependencies": {
"@ipfs-meshkit/core": "*",
"@ipfs-meshkit/node": "*"
},
"devDependencies": {
"@types/node": "^25.9.3"
}
}
12 changes: 11 additions & 1 deletion packages/meshkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@ export type {
IPFSNodeHandle,
StartIPFSNodeOptions,
} from '@ipfs-meshkit/node';
export { MeshkitNodeError, startIPFSNode, stopIPFSNode } from '@ipfs-meshkit/node';
export {
DEFAULT_REPO,
MeshkitNodeError,
listPins,
resolveRepoPath,
startIPFSNode,
stopIPFSNode,
} from '@ipfs-meshkit/node';

export type {
LocalNodeOption,
MeshkitBootstrapOptions,
MeshkitBootstrapResult,
} from './init.js';
export { init } from './init.js';

export type { GracefulShutdownOptions } from './shutdown.js';
export { setupGracefulShutdown } from './shutdown.js';
10 changes: 6 additions & 4 deletions packages/meshkit/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Meshkit as CoreMeshkit } from '@ipfs-meshkit/core';
import { startIPFSNode } from '@ipfs-meshkit/node';
import { DEFAULT_REPO, startIPFSNode } from '@ipfs-meshkit/node';
import type { StartIPFSNodeOptions, IPFSNodeHandle } from '@ipfs-meshkit/node';
import type { Meshkit, MeshkitInitOptions } from '@ipfs-meshkit/core';
import { MeshkitError } from '@ipfs-meshkit/core';
Expand All @@ -14,7 +14,7 @@ export interface MeshkitBootstrapOptions extends Omit<MeshkitInitOptions, 'nodes

/**
* Start or attach to a local Kubo daemon before connecting.
* When `true`, uses default local settings (`127.0.0.1:5001`).
* When `true`, uses `127.0.0.1:5001` and stores data in `./.ipfs`.
*/
localNode?: LocalNodeOption;
}
Expand Down Expand Up @@ -43,8 +43,10 @@ export async function init(
const nodes = [...(options.nodes ?? [])];

if (options.localNode) {
const nodeOptions =
typeof options.localNode === 'object' ? options.localNode : {};
const nodeOptions: StartIPFSNodeOptions =
typeof options.localNode === 'object'
? { repo: DEFAULT_REPO, ...options.localNode }
: { repo: DEFAULT_REPO };
localNode = await startIPFSNode(nodeOptions);
if (!nodes.includes(localNode.url)) {
nodes.unshift(localNode.url);
Expand Down
57 changes: 57 additions & 0 deletions packages/meshkit/src/shutdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { stopIPFSNode } from '@ipfs-meshkit/node';
import type { IPFSNodeHandle } from '@ipfs-meshkit/node';

export interface GracefulShutdownOptions {
/** Called before Kubo is stopped. Use to close HTTP servers, DB pools, etc. */
onShutdown?: () => void | Promise<void>;

/** Exit the process after shutdown. Defaults to `true`. */
exit?: boolean;

/** Exit code. Defaults to `0`. */
exitCode?: number;
}

/**
* Register SIGINT (Ctrl+C) and SIGTERM handlers that stop a managed Kubo daemon
* gracefully so the repo on disk (e.g. `./.ipfs`) is left in a consistent state.
*/
export function setupGracefulShutdown(
localNode: IPFSNodeHandle | undefined,
options: GracefulShutdownOptions = {},
): void {
let shuttingDown = false;
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];

const handle = (signal: NodeJS.Signals) => {
if (shuttingDown) {
return;
}
shuttingDown = true;

void (async () => {
try {
if (options.onShutdown) {
await options.onShutdown();
}
if (localNode?.managed) {
await stopIPFSNode(localNode);
}
} catch (error) {
console.error(`Error during ${signal} shutdown:`, error);
if (options.exit !== false) {
process.exit(1);
}
return;
}

if (options.exit !== false) {
process.exit(options.exitCode ?? 0);
}
})();
};

for (const signal of signals) {
process.on(signal, () => handle(signal));
}
}
Loading