rasler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ import { Router } from 'express';
2
+ import {
3
+ parseMasl,
4
+ maslContentHeaders,
5
+ maslIsBundle,
6
+ resolveBundleEntry,
7
+ maslLinkedCids,
8
+ findBundleHeadersForCid,
9
+ } from '../masl/document.js';
10
+ import { isMaslCid, cidToUnencodedDigest } from '../crypto/cid.js';
11
+
12
+ // Determine the path suffix after the CID in the request URL.
13
+ // Returns '' for the path-free form (no trailing slash or path segment),
14
+ // '/' for a trailing-slash-only request, '/style.css' for non-root, etc.
15
+ // Using req.path directly avoids the ambiguity in how Express 5 populates
16
+ // req.params.path for trailing-slash-only URLs.
17
+ function getPathAfterCid(req, cid) {
18
+ const cidPrefix = '/.well-known/rasl/' + cid;
19
+ return req.path.startsWith(cidPrefix) ? req.path.slice(cidPrefix.length) : '';
20
+ }
21
+
22
+ // Streams a data CID's bytes to the response. Uses getContentStream so that
23
+ // static (filesystem-backed) entries are served without copying to memory.
24
+ function pipeContent(store, cid, res, _next) {
25
+ const result = store.getContentStream(cid);
26
+ if (!result) return res.status(502).json({ error: 'Content unavailable' });
27
+ res.set('content-length', String(result.meta.size));
28
+ res.status(200);
29
+ result.stream.pipe(res);
30
+ }
31
+
32
+ // Base RASL router: serves locally held content. On miss, calls next() so
33
+ // overlay routing middleware (or a 404 terminator) can take over.
34
+ export function makeRaslRouter({ store }) {
35
+ const router = Router();
36
+
37
+ function handle(req, res, next) {
38
+ const { cid } = req.params;
39
+ const meta = store.getContentMeta(cid);
40
+ if (!meta) return next();
41
+
42
+ store.recordRequest(cid);
43
+
44
+ const afterCid = getPathAfterCid(req, cid);
45
+ const isPathFree = afterCid === '';
46
+
47
+ if (isPathFree) {
48
+ // Path-free form: always raw bytes. No MASL path resolution.
49
+ if (isMaslCid(cid)) {
50
+ // MASL CIDs are always in the blob store (never static).
51
+ const entry = store.getContent(cid);
52
+ if (!entry) return next();
53
+ res.set({ 'content-type': 'application/octet-stream' });
54
+ res.set('unencoded-digest', cidToUnencodedDigest(cid));
55
+ return res.status(200).send(entry.bytes);
56
+ }
57
+ // Data CID: surface the MASL-derived Content-Type when available.
58
+ // This is content negotiation, not path resolution. For bundle MASLs
59
+ // (used by static roots), the content-type lives on the resource entry,
60
+ // not the top-level document.
61
+ let headers = { 'content-type': 'application/octet-stream' };
62
+ if (meta.masl_cid) {
63
+ const maslEntry = store.getContent(meta.masl_cid);
64
+ if (maslEntry) {
65
+ try {
66
+ const doc = parseMasl(maslEntry.bytes);
67
+ headers = maslIsBundle(doc)
68
+ ? (findBundleHeadersForCid(doc, cid) ?? headers)
69
+ : maslContentHeaders(doc);
70
+ } catch { /* ignore */ }
71
+ }
72
+ }
73
+ res.set(headers);
74
+ res.set('unencoded-digest', cidToUnencodedDigest(cid));
75
+ return pipeContent(store, cid, res, next);
76
+ }
77
+
78
+ // Path-bearing form: resolve path against MASL document structure.
79
+ // afterCid always begins with '/', e.g. '/', '/style.css', '/a/b/c.html'.
80
+ const path = afterCid;
81
+
82
+ if (isMaslCid(cid)) {
83
+ const entry = store.getContent(cid);
84
+ if (!entry) return next();
85
+ let doc;
86
+ try { doc = parseMasl(entry.bytes); } catch {
87
+ return res.status(500).json({ error: 'Invalid MASL document' });
88
+ }
89
+
90
+ if (maslIsBundle(doc)) {
91
+ // Bundle Mode: look up path in the resources map.
92
+ const resolved = resolveBundleEntry(doc, path);
93
+ if (!resolved) return res.status(404).json({ error: 'Not found' });
94
+ store.recordRequest(resolved.cid);
95
+ res.set(resolved.headers);
96
+ res.set('unencoded-digest', cidToUnencodedDigest(resolved.cid));
97
+ return pipeContent(store, resolved.cid, res, next);
98
+ }
99
+
100
+ // Single Mode: has src but no resources. Only path '/' is valid.
101
+ const links = maslLinkedCids(doc);
102
+ const srcCid = links.length > 0 ? links[0].cid : null;
103
+ if (srcCid) {
104
+ if (path !== '/') return res.status(404).json({ error: 'Not found' });
105
+ store.recordRequest(srcCid);
106
+ res.set(maslContentHeaders(doc));
107
+ res.set('unencoded-digest', cidToUnencodedDigest(srcCid));
108
+ return pipeContent(store, srcCid, res, next);
109
+ }
110
+
111
+ // Other DRISL map (neither resources nor src): fall through to non-MASL handling.
112
+ }
113
+
114
+ // Non-MASL CID (or unrecognised DRISL map): only path '/' returns raw bytes;
115
+ // any other path is not found.
116
+ if (path !== '/') return res.status(404).json({ error: 'Not found' });
117
+ res.set({ 'content-type': 'application/octet-stream' });
118
+ res.set('unencoded-digest', cidToUnencodedDigest(cid));
119
+ return pipeContent(store, cid, res, next);
120
+ }
121
+
122
+ router.get('/.well-known/rasl/:cid', handle);
123
+ router.get('/.well-known/rasl/:cid/*path', handle);
124
+ router.head('/.well-known/rasl/:cid', handle);
125
+ router.head('/.well-known/rasl/:cid/*path', handle);
126
+
127
+ return router;
128
+ }
129
+
130
+ // Terminator: turns a fully-fallthrough RASL request into a 404.
131
+ export function makeRaslNotFoundHandler() {
132
+ const router = Router();
133
+ const notFound = (req, res) => res.status(404).json({ error: 'Not found' });
134
+ router.get('/.well-known/rasl/:cid', notFound);
135
+ router.get('/.well-known/rasl/:cid/*path', notFound);
136
+ router.head('/.well-known/rasl/:cid', notFound);
137
+ router.head('/.well-known/rasl/:cid/*path', notFound);
138
+ return router;
139
+ }
package/src/server.js ADDED
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, resolve } from 'path';
4
+ import express from 'express';
5
+ import swaggerUi from 'swagger-ui-express';
6
+ import { makeRaslRouter } from './routes/rasl.js';
7
+ import { makeOperatorStatusTerminator } from './routes/operator.js';
8
+ import { makeMountPointRouter } from './routes/mountPoints.js';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ function mergeSpecs(base, overlay) {
13
+ return {
14
+ ...base,
15
+ ...overlay,
16
+ info: { ...base.info, ...overlay.info },
17
+ components: {
18
+ ...base.components,
19
+ ...overlay.components,
20
+ schemas: {
21
+ ...(base.components?.schemas ?? {}),
22
+ ...(overlay.components?.schemas ?? {}),
23
+ },
24
+ securitySchemes: {
25
+ ...(base.components?.securitySchemes ?? {}),
26
+ ...(overlay.components?.securitySchemes ?? {}),
27
+ },
28
+ },
29
+ tags: [
30
+ ...(base.tags ?? []),
31
+ ...(overlay.tags ?? []).filter(t => !(base.tags ?? []).some(bt => bt.name === t.name)),
32
+ ],
33
+ paths: { ...(base.paths ?? {}), ...(overlay.paths ?? {}) },
34
+ };
35
+ }
36
+
37
+ // Mounts RASLer middleware (static files, mount-point router, RASL router,
38
+ // Swagger UI) onto an existing Express app. Does not set trust proxy or mount
39
+ // the operator router — callers handle those before calling finalizeApp().
40
+ // openApiOverlays: array of file paths to OpenAPI overlay specs to merge in.
41
+ export function addRaslerMiddleware(app, { store, config, openApiOverlays = [] }) {
42
+ app.use(makeMountPointRouter({ store, mountPoints: config.mountPoints ?? [], selfOrigin: config.origin }));
43
+ app.use(makeRaslRouter({ store }));
44
+
45
+ // Swagger UI — mounted early so the browser can load it without credentials.
46
+ // Use the Authorize button to set x-rasl-operator-secret.
47
+ if (config.swaggerUi) {
48
+ const operatorPrefix = config.operatorApiPathPrefix ?? '';
49
+ let spec = JSON.parse(readFileSync(resolve(__dirname, '..', 'openapi.json'), 'utf8'));
50
+ for (const overlayPath of openApiOverlays) {
51
+ const overlay = JSON.parse(readFileSync(overlayPath, 'utf8'));
52
+ spec = mergeSpecs(spec, overlay);
53
+ }
54
+ spec.servers = [{ url: operatorPrefix || '/', description: 'This node' }];
55
+ const swaggerPath = `${operatorPrefix}/api-docs`;
56
+ app.use(swaggerPath, swaggerUi.serve, swaggerUi.setup(spec, {
57
+ swaggerOptions: { persistAuthorization: true },
58
+ }));
59
+ console.log(`Swagger UI enabled at ${swaggerPath}`);
60
+ }
61
+ }
62
+
63
+ // Convenience factory: creates a new Express app with trust proxy enabled,
64
+ // then calls addRaslerMiddleware. Use addRaslerMiddleware directly when adding
65
+ // RASLer to an existing app.
66
+ export function createApp({ store, config, openApiOverlays = [] }) {
67
+ const app = express();
68
+ app.set('trust proxy', 1);
69
+ addRaslerMiddleware(app, { store, config, openApiOverlays });
70
+ return app;
71
+ }
72
+
73
+ // Adds the RASL 404 terminator, operator /status terminator, and error
74
+ // handler. Call after all overlay middleware and the operator router have
75
+ // been mounted.
76
+ export function finalizeApp(app, config) {
77
+ const prefix = (config.operatorApiPathPrefix ?? '') || '/';
78
+ app.use(prefix, makeOperatorStatusTerminator());
79
+ app.use((err, req, res, _next) => {
80
+ console.error(err);
81
+ res.status(500).json({ error: 'Internal server error' });
82
+ });
83
+ }
package/src/static.js ADDED
@@ -0,0 +1,168 @@
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import { join, relative, basename, dirname, sep } from 'node:path';
3
+ import micromatch from 'micromatch';
4
+ import { computeDataCid } from './crypto/cid.js';
5
+ import { createBundleMasl, parseMasl } from './masl/document.js';
6
+ import { mimeType } from './util/mime.js';
7
+ import {
8
+ dbPutStaticContent,
9
+ dbGetContentBySourcePath,
10
+ dbListStaticContent,
11
+ dbDeleteContent,
12
+ } from './storage/db.js';
13
+
14
+ // Walks the prev chain from prevMaslCid (depth 2 relative to the new MASL)
15
+ // and unpins any entry whose depth exceeds maxHistory.
16
+ function pruneHistory(store, prevMaslCid, maxHistory) {
17
+ let cid = prevMaslCid;
18
+ let depth = 2; // new MASL is depth 1, its prev is depth 2
19
+ while (cid) {
20
+ const entry = store.getContent(cid);
21
+ if (!entry) break;
22
+ if (depth > maxHistory) store.setPinned(cid, false);
23
+ try {
24
+ cid = parseMasl(entry.bytes).prev?.$link ?? null;
25
+ } catch { break; }
26
+ depth++;
27
+ }
28
+ }
29
+
30
+ async function* walkDir(dir, rootDir, ignore) {
31
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
32
+ const full = join(dir, entry.name);
33
+ if (ignore.length > 0) {
34
+ const rel = relative(rootDir, full).replace(/\\/g, '/');
35
+ if (micromatch.isMatch(rel, ignore)) continue;
36
+ }
37
+ if (entry.isDirectory()) yield* walkDir(full, rootDir, ignore);
38
+ else if (entry.isFile()) yield full;
39
+ }
40
+ }
41
+
42
+ // Scans rootPath, registers each file's CID in the store's DB (with
43
+ // source_path set), and — when generateMasl is true (the default) —
44
+ // generates a bundle MASL for the root and returns its CID. Files are never
45
+ // copied to the blob store. Symlinks that resolve outside the root are
46
+ // silently skipped.
47
+ //
48
+ // When generateMasl is false, files are stored as plain blobs only (no MASL
49
+ // document is created and null is returned). This is useful for roots that
50
+ // exist purely to make a set of named blobs available by CID.
51
+ //
52
+ // On repeated calls, files whose size and mtime match the stored values are
53
+ // not re-read or re-hashed. DB entries for files that no longer exist are
54
+ // removed.
55
+ export async function indexStaticRoot(rootPath, store, { maxHistory = null, ignore = [], generateMasl = true } = {}) {
56
+ const realRoot = await realpath(rootPath);
57
+ const fileInfos = [];
58
+ const visitedPaths = new Set();
59
+ let changed = false;
60
+
61
+ for await (const filePath of walkDir(realRoot, realRoot, ignore)) {
62
+ let realFile;
63
+ try { realFile = await realpath(filePath); } catch { continue; }
64
+ if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) continue;
65
+
66
+ visitedPaths.add(realFile);
67
+
68
+ const { size, mtimeMs } = await stat(realFile);
69
+ const mtime = Math.round(mtimeMs);
70
+
71
+ // Cache hit: size and mtime both match → file unchanged → reuse stored CID.
72
+ const existing = dbGetContentBySourcePath(store.db, realFile);
73
+ let cid;
74
+ if (existing && existing.size === size && existing.source_mtime === mtime) {
75
+ cid = existing.cid;
76
+ } else {
77
+ changed = true;
78
+ const bytes = await readFile(realFile);
79
+ cid = await computeDataCid(bytes);
80
+ }
81
+
82
+ const contentType = mimeType(filePath);
83
+ const relPath = '/' + relative(realRoot, filePath).replace(/\\/g, '/');
84
+ fileInfos.push({ realPath: realFile, relPath, cid, size, mtime, contentType });
85
+ }
86
+
87
+ // Remove DB entries for files deleted since the last startup.
88
+ for (const entry of dbListStaticContent(store.db)) {
89
+ if (entry.source_path?.startsWith(realRoot + sep) && !visitedPaths.has(entry.source_path)) {
90
+ changed = true;
91
+ dbDeleteContent(store.db, entry.cid);
92
+ }
93
+ }
94
+
95
+ if (fileInfos.length === 0) return null;
96
+
97
+ if (!generateMasl) {
98
+ // Nothing changed: files are already registered in the DB — no work needed.
99
+ if (!changed) return null;
100
+ const seen = new Set();
101
+ for (const { cid, size, mtime, realPath } of fileInfos) {
102
+ if (!seen.has(cid)) {
103
+ seen.add(cid);
104
+ dbPutStaticContent(store.db, cid, { maslCid: null, size, sourcePath: realPath, sourceMtime: mtime });
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // Find the existing MASL CID for this root (if any) to link as prev.
111
+ const prevEntry = dbListStaticContent(store.db)
112
+ .find(e => e.source_path?.startsWith(realRoot + sep));
113
+ const prevMaslCid = prevEntry?.masl_cid ?? null;
114
+
115
+ // Nothing changed — reuse the existing MASL rather than generating a new one.
116
+ if (!changed && prevMaslCid) {
117
+ store.staticRootMasls.set(realRoot, prevMaslCid);
118
+ return prevMaslCid;
119
+ }
120
+
121
+ // Sort for deterministic MASL CIDs across restarts.
122
+ fileInfos.sort((a, b) => a.relPath.localeCompare(b.relPath));
123
+
124
+ const resources = [];
125
+ for (const { relPath, cid, size, contentType } of fileInfos) {
126
+ resources.push({ path: relPath, cid, size, contentType });
127
+ if (basename(relPath) === 'index.html') {
128
+ const dir = dirname(relPath);
129
+ resources.push({ path: dir === '/' ? '/' : dir + '/', cid, size, contentType });
130
+ }
131
+ }
132
+
133
+ const name = basename(realRoot);
134
+ const { cborBytes, maslCid } = await createBundleMasl({ name, resources, prevMaslCid });
135
+
136
+ store.putContent(maslCid, Buffer.from(cborBytes), { pinned: true });
137
+ store.staticRootMasls.set(realRoot, maslCid);
138
+
139
+ if (maxHistory != null && prevMaslCid) pruneHistory(store, prevMaslCid, maxHistory);
140
+
141
+ const seen = new Set();
142
+ for (const { cid, size, mtime, realPath } of fileInfos) {
143
+ if (!seen.has(cid)) {
144
+ seen.add(cid);
145
+ dbPutStaticContent(store.db, cid, { maslCid, size, sourcePath: realPath, sourceMtime: mtime });
146
+ }
147
+ }
148
+
149
+ return maslCid;
150
+ }
151
+
152
+ export async function indexStaticRoots(staticRoots, store, { maxHistory = null } = {}) {
153
+ for (const root of staticRoots) {
154
+ const { directory, ignore = [], generateMasl = true } = root;
155
+ try {
156
+ const maslCid = await indexStaticRoot(directory, store, { maxHistory, ignore, generateMasl });
157
+ if (maslCid) {
158
+ console.log(`Static root indexed: ${directory} → MASL ${maslCid}`);
159
+ } else if (!generateMasl) {
160
+ console.log(`Static root indexed: ${directory} (blobs only, no MASL)`);
161
+ } else {
162
+ console.warn(`Static root empty, skipped: ${directory}`);
163
+ }
164
+ } catch (err) {
165
+ console.error(`Failed to index static root ${directory}: ${err.message}`);
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,133 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const BASE_SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS content (
7
+ cid TEXT PRIMARY KEY,
8
+ masl_cid TEXT,
9
+ size INTEGER NOT NULL,
10
+ pinned INTEGER NOT NULL DEFAULT 0,
11
+ last_requested INTEGER,
12
+ source_path TEXT,
13
+ source_mtime INTEGER
14
+ );
15
+ CREATE TABLE IF NOT EXISTS mount_points (
16
+ hostname TEXT NOT NULL,
17
+ mount_path TEXT NOT NULL DEFAULT '',
18
+ masl_cid TEXT NOT NULL,
19
+ PRIMARY KEY (hostname, mount_path)
20
+ );
21
+ `;
22
+
23
+ export function openDb(dataDir) {
24
+ mkdirSync(dataDir, { recursive: true });
25
+ const db = new DatabaseSync(join(dataDir, 'rasler.db'));
26
+ db.exec(BASE_SCHEMA);
27
+ return db;
28
+ }
29
+
30
+ // ---- Content ----
31
+
32
+ // pinned = 2: static filesystem-backed content. Never evicted, not counted
33
+ // toward pool or pinned capacity. source_path is the resolved absolute path.
34
+ export function dbPutStaticContent(db, cid, { maslCid = null, size, sourcePath, sourceMtime }) {
35
+ db.prepare(`
36
+ INSERT INTO content (cid, masl_cid, size, pinned, source_path, source_mtime, last_requested)
37
+ VALUES (?, ?, ?, 2, ?, ?, NULL)
38
+ ON CONFLICT(cid) DO UPDATE SET
39
+ masl_cid = excluded.masl_cid,
40
+ size = excluded.size,
41
+ pinned = 2,
42
+ source_path = excluded.source_path,
43
+ source_mtime = excluded.source_mtime
44
+ `).run(cid, maslCid, size, sourcePath, sourceMtime);
45
+ }
46
+
47
+ export function dbGetContentBySourcePath(db, sourcePath) {
48
+ return db.prepare('SELECT * FROM content WHERE source_path = ?').get(sourcePath) ?? null;
49
+ }
50
+
51
+ export function dbListStaticContent(db) {
52
+ return db.prepare('SELECT * FROM content WHERE pinned = 2').all();
53
+ }
54
+
55
+ export function dbPutContent(db, cid, { maslCid = null, size, pinned = 0, lastRequested = null }) {
56
+ const stmt = db.prepare(`
57
+ INSERT INTO content (cid, masl_cid, size, pinned, last_requested)
58
+ VALUES (?, ?, ?, ?, ?)
59
+ ON CONFLICT(cid) DO UPDATE SET
60
+ masl_cid = excluded.masl_cid,
61
+ size = excluded.size,
62
+ pinned = MAX(pinned, excluded.pinned),
63
+ last_requested = excluded.last_requested
64
+ `);
65
+ stmt.run(cid, maslCid, size, pinned ? 1 : 0, lastRequested);
66
+ }
67
+
68
+ export function dbGetContent(db, cid) {
69
+ return db.prepare('SELECT * FROM content WHERE cid = ?').get(cid) ?? null;
70
+ }
71
+
72
+ export function dbHasContent(db, cid) {
73
+ return !!db.prepare('SELECT 1 FROM content WHERE cid = ?').get(cid);
74
+ }
75
+
76
+ export function dbListContent(db) {
77
+ return db.prepare('SELECT * FROM content').all();
78
+ }
79
+
80
+ export function dbCountContent(db) {
81
+ return db.prepare('SELECT COUNT(*) AS cnt FROM content').get().cnt;
82
+ }
83
+
84
+ export function dbListContentPage(db, limit, cursor) {
85
+ if (cursor) {
86
+ return db.prepare('SELECT * FROM content WHERE cid > ? ORDER BY cid LIMIT ?').all(cursor, limit);
87
+ }
88
+ return db.prepare('SELECT * FROM content ORDER BY cid LIMIT ?').all(limit);
89
+ }
90
+
91
+ export function dbDeleteContent(db, cid) {
92
+ db.prepare('DELETE FROM content WHERE cid = ?').run(cid);
93
+ }
94
+
95
+ export function dbRecordRequest(db, cid) {
96
+ db.prepare('UPDATE content SET last_requested = ? WHERE cid = ?').run(Date.now(), cid);
97
+ }
98
+
99
+ export function dbSetPinned(db, cid, pinned) {
100
+ db.prepare('UPDATE content SET pinned = ? WHERE cid = ?').run(pinned ? 1 : 0, cid);
101
+ }
102
+
103
+ export function dbGetTotalPoolSize(db) {
104
+ const row = db.prepare('SELECT SUM(size) AS total FROM content WHERE pinned = 0').get();
105
+ return row?.total ?? 0;
106
+ }
107
+
108
+ export function dbGetTotalPinnedSize(db) {
109
+ const row = db.prepare('SELECT SUM(size) AS total FROM content WHERE pinned = 1').get();
110
+ return row?.total ?? 0;
111
+ }
112
+
113
+ export function dbCountPinned(db) {
114
+ const row = db.prepare('SELECT COUNT(*) AS cnt FROM content WHERE pinned = 1').get();
115
+ return row?.cnt ?? 0;
116
+ }
117
+
118
+ // ---- Mount points ----
119
+
120
+ export function dbSetMountPoint(db, hostname, mountPath, maslCid) {
121
+ db.prepare(`
122
+ INSERT INTO mount_points (hostname, mount_path, masl_cid) VALUES (?, ?, ?)
123
+ ON CONFLICT(hostname, mount_path) DO UPDATE SET masl_cid = excluded.masl_cid
124
+ `).run(hostname, mountPath, maslCid);
125
+ }
126
+
127
+ export function dbDeleteMountPoint(db, hostname, mountPath) {
128
+ db.prepare('DELETE FROM mount_points WHERE hostname = ? AND mount_path = ?').run(hostname, mountPath);
129
+ }
130
+
131
+ export function dbListMountPoints(db) {
132
+ return db.prepare('SELECT hostname, mount_path, masl_cid FROM mount_points').all();
133
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, createReadStream } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ function blobPath(dataDir, cid) {
5
+ // Use chars [-3, -2] of the CID as the shard directory (e.g. "nq" for ...vnq4).
6
+ // The first two characters of every CIDv1 base32 string are always "ba", so
7
+ // sharding by prefix gives no distribution benefit.
8
+ const shard = cid.slice(-3, -1);
9
+ return join(dataDir, 'blobs', shard, cid);
10
+ }
11
+
12
+ export function writeContent(dataDir, cid, bytes) {
13
+ const path = blobPath(dataDir, cid);
14
+ mkdirSync(join(path, '..'), { recursive: true });
15
+ writeFileSync(path, bytes);
16
+ }
17
+
18
+ export function readContent(dataDir, cid) {
19
+ const path = blobPath(dataDir, cid);
20
+ if (!existsSync(path)) return null;
21
+ return readFileSync(path);
22
+ }
23
+
24
+ export function deleteContent(dataDir, cid) {
25
+ const path = blobPath(dataDir, cid);
26
+ if (existsSync(path)) unlinkSync(path);
27
+ }
28
+
29
+ export function hasContent(dataDir, cid) {
30
+ return existsSync(blobPath(dataDir, cid));
31
+ }
32
+
33
+ export function readContentStream(dataDir, cid) {
34
+ const path = blobPath(dataDir, cid);
35
+ if (!existsSync(path)) return null;
36
+ return createReadStream(path);
37
+ }
38
+
39
+ export function readContentStreamFromPath(absolutePath) {
40
+ if (!existsSync(absolutePath)) return null;
41
+ return createReadStream(absolutePath);
42
+ }
43
+
44
+ export function readContentFromPath(absolutePath) {
45
+ if (!existsSync(absolutePath)) return null;
46
+ return readFileSync(absolutePath);
47
+ }