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.
- package/LICENSE +180 -0
- package/README.md +140 -0
- package/openapi.json +859 -0
- package/package.json +68 -0
- package/src/config.js +81 -0
- package/src/crypto/cid.js +49 -0
- package/src/index.js +45 -0
- package/src/masl/document.js +148 -0
- package/src/middleware/auth.js +11 -0
- package/src/middleware/cors.js +31 -0
- package/src/routes/mountPoints.js +86 -0
- package/src/routes/operator.js +944 -0
- package/src/routes/rasl.js +139 -0
- package/src/server.js +83 -0
- package/src/static.js +168 -0
- package/src/storage/db.js +133 -0
- package/src/storage/files.js +47 -0
- package/src/storage/store.js +171 -0
- package/src/util/env.js +32 -0
- package/src/util/loadRaslerConfig.js +14 -0
- package/src/util/mime.js +29 -0
- package/src/util/normalizeMountPath.js +12 -0
- package/src/util/parseJsonConfig.js +57 -0
- package/src/util/parseSize.js +16 -0
- package/src/watcher.js +42 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dbPutContent, dbGetContent, dbHasContent, dbListContent, dbDeleteContent,
|
|
3
|
+
dbRecordRequest, dbSetPinned, dbGetTotalPoolSize, dbGetTotalPinnedSize,
|
|
4
|
+
dbCountPinned, dbCountContent, dbListContentPage,
|
|
5
|
+
dbSetMountPoint, dbDeleteMountPoint, dbListMountPoints,
|
|
6
|
+
} from './db.js';
|
|
7
|
+
import { sortMountPoints } from '../util/parseJsonConfig.js';
|
|
8
|
+
import {
|
|
9
|
+
writeContent, readContent, readContentFromPath,
|
|
10
|
+
readContentStream, readContentStreamFromPath, deleteContent,
|
|
11
|
+
} from './files.js';
|
|
12
|
+
import { realpathSync } from 'fs';
|
|
13
|
+
import { sep } from 'path';
|
|
14
|
+
|
|
15
|
+
// Default eviction policy: oldest unpinned by last_requested (LRU).
|
|
16
|
+
// An overlay can supply a network-aware policy that considers
|
|
17
|
+
// replica counts and primary-holder status.
|
|
18
|
+
function defaultFindEvictionCandidate(store) {
|
|
19
|
+
const row = store.db.prepare(`
|
|
20
|
+
SELECT cid FROM content
|
|
21
|
+
WHERE pinned = 0
|
|
22
|
+
ORDER BY last_requested ASC NULLS FIRST
|
|
23
|
+
LIMIT 1
|
|
24
|
+
`).get();
|
|
25
|
+
return row?.cid ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Store {
|
|
29
|
+
constructor(db, dataDir, totalCapacity, { findEvictionCandidate, staticRoots = [] } = {}) {
|
|
30
|
+
this.db = db;
|
|
31
|
+
this.dataDir = dataDir;
|
|
32
|
+
this.totalCapacity = totalCapacity;
|
|
33
|
+
this._findEvictionCandidate = findEvictionCandidate ?? defaultFindEvictionCandidate;
|
|
34
|
+
// Pre-resolve static roots once so symlink checks at serve time are fast.
|
|
35
|
+
this._realStaticRoots = staticRoots.map(r => {
|
|
36
|
+
const dir = typeof r === 'string' ? r : r.directory;
|
|
37
|
+
try { return realpathSync(dir); } catch { return dir; }
|
|
38
|
+
});
|
|
39
|
+
// Populated by indexStaticRoot after each root is indexed. Maps realpath → maslCid.
|
|
40
|
+
this.staticRootMasls = new Map();
|
|
41
|
+
// Runtime mount point mappings set via operator API. Persisted in SQLite.
|
|
42
|
+
// Array of {hostname, prefix, maslCid} — hostname='' means any host.
|
|
43
|
+
// Sorted: longer prefix first; equal prefix: specific hostname before wildcard.
|
|
44
|
+
// Takes priority over staticRootMasls in mount-point routing.
|
|
45
|
+
const runtimeRows = dbListMountPoints(db)
|
|
46
|
+
.map(row => ({ hostname: row.hostname, prefix: row.mount_path, maslCid: row.masl_cid }));
|
|
47
|
+
sortMountPoints(runtimeRows);
|
|
48
|
+
this.runtimeMountPoints = runtimeRows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
putContent(cid, bytes, { maslCid = null, pinned = false } = {}) {
|
|
52
|
+
writeContent(this.dataDir, cid, bytes);
|
|
53
|
+
dbPutContent(this.db, cid, {
|
|
54
|
+
maslCid,
|
|
55
|
+
size: bytes.length,
|
|
56
|
+
pinned,
|
|
57
|
+
lastRequested: null,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getContent(cid) {
|
|
62
|
+
const meta = dbGetContent(this.db, cid);
|
|
63
|
+
if (!meta) return null;
|
|
64
|
+
const bytes = meta.source_path
|
|
65
|
+
? readContentFromPath(meta.source_path)
|
|
66
|
+
: readContent(this.dataDir, cid);
|
|
67
|
+
if (!bytes) return null;
|
|
68
|
+
return { bytes, meta };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Returns { stream: ReadStream, meta } for efficient large-file serving,
|
|
72
|
+
// or null if the content is unavailable. For static entries, verifies
|
|
73
|
+
// that source_path still resolves to a path under a configured static root.
|
|
74
|
+
getContentStream(cid) {
|
|
75
|
+
const meta = dbGetContent(this.db, cid);
|
|
76
|
+
if (!meta) return null;
|
|
77
|
+
if (meta.source_path) {
|
|
78
|
+
let realFile;
|
|
79
|
+
try { realFile = realpathSync(meta.source_path); } catch { return null; }
|
|
80
|
+
const allowed = this._realStaticRoots.some(
|
|
81
|
+
root => realFile === root || realFile.startsWith(root + sep)
|
|
82
|
+
);
|
|
83
|
+
if (!allowed) return null;
|
|
84
|
+
const stream = readContentStreamFromPath(meta.source_path);
|
|
85
|
+
if (!stream) return null;
|
|
86
|
+
return { stream, meta };
|
|
87
|
+
}
|
|
88
|
+
const stream = readContentStream(this.dataDir, cid);
|
|
89
|
+
if (!stream) return null;
|
|
90
|
+
return { stream, meta };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getContentMeta(cid) {
|
|
94
|
+
return dbGetContent(this.db, cid);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
hasContent(cid) {
|
|
98
|
+
return dbHasContent(this.db, cid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
listContent() {
|
|
102
|
+
return dbListContent(this.db);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
countContent() {
|
|
106
|
+
return dbCountContent(this.db);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
listContentPage(limit, cursor) {
|
|
110
|
+
return dbListContentPage(this.db, limit, cursor);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
deleteContent(cid) {
|
|
114
|
+
const meta = dbGetContent(this.db, cid);
|
|
115
|
+
// Static content: bytes live on operator's filesystem; only remove the DB record.
|
|
116
|
+
if (!meta?.source_path) deleteContent(this.dataDir, cid);
|
|
117
|
+
dbDeleteContent(this.db, cid);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
recordRequest(cid) {
|
|
121
|
+
dbRecordRequest(this.db, cid);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setPinned(cid, pinned) {
|
|
125
|
+
dbSetPinned(this.db, cid, pinned);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getPoolUsed() {
|
|
129
|
+
return dbGetTotalPoolSize(this.db);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getPinnedUsed() {
|
|
133
|
+
return dbGetTotalPinnedSize(this.db);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getPoolAvailable() {
|
|
137
|
+
return Math.max(0, this.totalCapacity - this.getPoolUsed() - this.getPinnedUsed());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
countPinned() {
|
|
141
|
+
return dbCountPinned(this.db);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Evicts one CID if needed to free requiredBytes. Returns true if eviction
|
|
145
|
+
// was performed or not needed, false if impossible. The eviction policy is
|
|
146
|
+
// injected via the constructor; replica-row cleanup happens automatically
|
|
147
|
+
// via FK cascade on the replicas table.
|
|
148
|
+
setMountPoint(hostname, prefix, maslCid) {
|
|
149
|
+
dbSetMountPoint(this.db, hostname, prefix, maslCid);
|
|
150
|
+
this.runtimeMountPoints = this.runtimeMountPoints.filter(
|
|
151
|
+
mp => !(mp.hostname === hostname && mp.prefix === prefix)
|
|
152
|
+
);
|
|
153
|
+
this.runtimeMountPoints.push({ hostname, prefix, maslCid });
|
|
154
|
+
sortMountPoints(this.runtimeMountPoints);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
deleteMountPoint(hostname, prefix) {
|
|
158
|
+
dbDeleteMountPoint(this.db, hostname, prefix);
|
|
159
|
+
this.runtimeMountPoints = this.runtimeMountPoints.filter(
|
|
160
|
+
mp => !(mp.hostname === hostname && mp.prefix === prefix)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
evictIfNeeded(requiredBytes) {
|
|
165
|
+
if (this.getPoolAvailable() >= requiredBytes) return true;
|
|
166
|
+
const cid = this._findEvictionCandidate(this);
|
|
167
|
+
if (!cid) return false;
|
|
168
|
+
this.deleteContent(cid);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/util/env.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
function loadEnvFile() {
|
|
5
|
+
try {
|
|
6
|
+
const raw = readFileSync(resolve(process.cwd(), '.env'), 'utf8');
|
|
7
|
+
for (const line of raw.split('\n')) {
|
|
8
|
+
const trimmed = line.trim();
|
|
9
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
10
|
+
const eq = trimmed.indexOf('=');
|
|
11
|
+
if (eq === -1) continue;
|
|
12
|
+
const key = trimmed.slice(0, eq).trim();
|
|
13
|
+
const val = trimmed.slice(eq + 1).trim();
|
|
14
|
+
if (!(key in process.env)) process.env[key] = val;
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// .env file is optional
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
loadEnvFile();
|
|
22
|
+
|
|
23
|
+
export function required(name) {
|
|
24
|
+
const val = process.env[name];
|
|
25
|
+
if (!val) throw new Error(`Missing required environment variable: ${name}`);
|
|
26
|
+
return val;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function optional(name, defaultValue) {
|
|
30
|
+
const val = process.env[name];
|
|
31
|
+
return val !== undefined && val !== '' ? val : defaultValue;
|
|
32
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Reads rasler.config.json from CWD. Returns the parsed object, or null if
|
|
5
|
+
// the file does not exist. Throws on malformed JSON.
|
|
6
|
+
export function loadRaslerConfig(configPath = 'rasler.config.json') {
|
|
7
|
+
try {
|
|
8
|
+
const raw = readFileSync(resolve(process.cwd(), configPath), 'utf8');
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
if (err.code === 'ENOENT') return null;
|
|
12
|
+
throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err });
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/util/mime.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const MIME_TYPES = {
|
|
2
|
+
'.html': 'text/html; charset=utf-8',
|
|
3
|
+
'.css': 'text/css',
|
|
4
|
+
'.js': 'application/javascript',
|
|
5
|
+
'.mjs': 'application/javascript',
|
|
6
|
+
'.json': 'application/json',
|
|
7
|
+
'.xml': 'application/xml',
|
|
8
|
+
'.txt': 'text/plain',
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.jpg': 'image/jpeg',
|
|
11
|
+
'.jpeg': 'image/jpeg',
|
|
12
|
+
'.gif': 'image/gif',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.ico': 'image/x-icon',
|
|
16
|
+
'.woff': 'font/woff',
|
|
17
|
+
'.woff2': 'font/woff2',
|
|
18
|
+
'.ttf': 'font/ttf',
|
|
19
|
+
'.otf': 'font/otf',
|
|
20
|
+
'.mp4': 'video/mp4',
|
|
21
|
+
'.webm': 'video/webm',
|
|
22
|
+
'.pdf': 'application/pdf',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
import { extname } from 'path';
|
|
26
|
+
|
|
27
|
+
export function mimeType(filePath) {
|
|
28
|
+
return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Normalizes a URL path prefix for use as a mount point:
|
|
2
|
+
// '/' → '' (root — represented as empty string internally)
|
|
3
|
+
// '/docs/' → '/docs'
|
|
4
|
+
// 'docs' → '/docs' (adds leading slash)
|
|
5
|
+
// '' → ''
|
|
6
|
+
export function normalizeMountPath(raw) {
|
|
7
|
+
let p = (raw ?? '').trim();
|
|
8
|
+
if (!p) return '';
|
|
9
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
10
|
+
while (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
11
|
+
return p === '/' ? '' : p;
|
|
12
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { normalizeMountPath } from './normalizeMountPath.js';
|
|
3
|
+
|
|
4
|
+
// Parses the staticRoots array from rasler.config.json.
|
|
5
|
+
// Each entry may be a string (path only) or an object { path, watch?, ignore?, generateMasl? }.
|
|
6
|
+
// Returns an array of { directory, watch, ignore, generateMasl }.
|
|
7
|
+
export function parseJsonStaticRoots(entries = []) {
|
|
8
|
+
return entries.flatMap(entry => {
|
|
9
|
+
if (typeof entry === 'string') {
|
|
10
|
+
const p = entry.trim();
|
|
11
|
+
if (!p) return [];
|
|
12
|
+
return [{ directory: resolve(p), watch: false, ignore: [], generateMasl: true }];
|
|
13
|
+
}
|
|
14
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.path === 'string' && entry.path.trim()) {
|
|
15
|
+
return [{
|
|
16
|
+
directory: resolve(entry.path.trim()),
|
|
17
|
+
watch: entry.watch === true,
|
|
18
|
+
ignore: Array.isArray(entry.ignore)
|
|
19
|
+
? entry.ignore.filter(s => typeof s === 'string')
|
|
20
|
+
: [],
|
|
21
|
+
generateMasl: entry.generateMasl !== false,
|
|
22
|
+
}];
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Parses the mountPoints array from rasler.config.json.
|
|
29
|
+
// Each entry: { hostname?, prefix?, directory }.
|
|
30
|
+
// hostname is optional; omitting it (or setting it to '') creates a wildcard entry
|
|
31
|
+
// that matches any Host: header value.
|
|
32
|
+
// Returns an array of { hostname, prefix, directory } sorted longest-prefix-first,
|
|
33
|
+
// with specific-hostname entries before wildcard entries at equal prefix lengths.
|
|
34
|
+
export function parseJsonMountPoints(entries = []) {
|
|
35
|
+
const points = [];
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
38
|
+
const hostname = typeof entry.hostname === 'string' ? entry.hostname.trim() : '';
|
|
39
|
+
const directory = typeof entry.directory === 'string' ? entry.directory.trim() : '';
|
|
40
|
+
if (!directory) continue;
|
|
41
|
+
points.push({
|
|
42
|
+
hostname,
|
|
43
|
+
prefix: normalizeMountPath(entry.prefix || ''),
|
|
44
|
+
directory: resolve(directory),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
sortMountPoints(points);
|
|
48
|
+
return points;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Longer prefix wins; equal prefix: specific hostname before wildcard ('').
|
|
52
|
+
export function sortMountPoints(points) {
|
|
53
|
+
points.sort((a, b) =>
|
|
54
|
+
b.prefix.length - a.prefix.length ||
|
|
55
|
+
(b.hostname ? 1 : 0) - (a.hostname ? 1 : 0)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const SIZE_UNITS = {
|
|
2
|
+
'': 1, B: 1,
|
|
3
|
+
K: 1024, KB: 1024,
|
|
4
|
+
M: 1024 ** 2, MB: 1024 ** 2,
|
|
5
|
+
G: 1024 ** 3, GB: 1024 ** 3,
|
|
6
|
+
T: 1024 ** 4, TB: 1024 ** 4,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function parseSize(value) {
|
|
10
|
+
const str = String(value).trim();
|
|
11
|
+
const match = str.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]*)$/);
|
|
12
|
+
if (!match) throw new Error(`Invalid size value: "${value}"`);
|
|
13
|
+
const unit = match[2].toUpperCase();
|
|
14
|
+
if (!(unit in SIZE_UNITS)) throw new Error(`Unknown size unit "${match[2]}" in: "${value}"`);
|
|
15
|
+
return Math.round(parseFloat(match[1]) * SIZE_UNITS[unit]);
|
|
16
|
+
}
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { watch } from 'node:fs/promises';
|
|
2
|
+
import { indexStaticRoot } from './static.js';
|
|
3
|
+
|
|
4
|
+
// Starts a recursive fs watcher for a single static root. Re-indexes the root
|
|
5
|
+
// after a 300 ms debounce whenever any file inside it changes.
|
|
6
|
+
function watchStaticRoot({ directory, ignore, generateMasl = true }, store, { maxHistory }) {
|
|
7
|
+
let debounceTimer = null;
|
|
8
|
+
|
|
9
|
+
(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const watcher = watch(directory, { recursive: true });
|
|
12
|
+
for await (const _ of watcher) {
|
|
13
|
+
clearTimeout(debounceTimer);
|
|
14
|
+
debounceTimer = setTimeout(async () => {
|
|
15
|
+
try {
|
|
16
|
+
const maslCid = await indexStaticRoot(directory, store, { maxHistory, ignore, generateMasl });
|
|
17
|
+
if (maslCid) {
|
|
18
|
+
console.log(`Static root re-indexed: ${directory} → MASL ${maslCid}`);
|
|
19
|
+
} else if (!generateMasl) {
|
|
20
|
+
console.log(`Static root re-indexed: ${directory} (blobs only, no MASL)`);
|
|
21
|
+
} else {
|
|
22
|
+
console.warn(`Static root re-indexed but empty: ${directory}`);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`Failed to re-index static root ${directory}: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
}, 300);
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'ABORT_ERR') {
|
|
31
|
+
console.error(`Watcher error for ${directory}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Starts watchers for every static root that has watch: true.
|
|
38
|
+
export function startStaticRootWatchers(staticRoots, store, { maxHistory = null } = {}) {
|
|
39
|
+
for (const root of staticRoots) {
|
|
40
|
+
if (root.watch) watchStaticRoot(root, store, { maxHistory });
|
|
41
|
+
}
|
|
42
|
+
}
|