nukebase 2.2.2 → 2.3.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/lib/storage.js ADDED
@@ -0,0 +1,227 @@
1
+ // Object storage for `nukebase backup --storage` and `nukebase push --include
2
+ // backup`. A project with object storage keeps its files (what the app stores
3
+ // with setFile) in a bucket instead of server/files. They move straight between
4
+ // this machine and the bucket through links the server signs for one file and
5
+ // one hour; none of the bytes pass through the project's server. Locally they
6
+ // live where a project without storage keeps them: server/files/<path>.
7
+ //
8
+ // Every transfer is IPv4: the storage provider stalls parallel uploads over
9
+ // IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
10
+ const fs = require('fs');
11
+ const https = require('https');
12
+ const http = require('http');
13
+ const { join, dirname, relative, sep } = require('path');
14
+ const { pipeline } = require('stream/promises');
15
+
16
+ const FILES_DIR = 'server/files';
17
+ const IDLE_MS = 30000;
18
+ const TRIES = 4;
19
+ const PARALLEL = 6;
20
+
21
+ // { storage: false } from a server without storage for this project, null from
22
+ // a server that predates it; otherwise { storage: true, files: { key: [size, mtime] } }.
23
+ async function manifest(API, authHeaders) {
24
+ const files = {};
25
+ let after = null;
26
+ for (;;) {
27
+ const res = await fetch(`${API}/storage-manifest`, {
28
+ method: 'POST',
29
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
30
+ body: JSON.stringify(after ? { after } : {})
31
+ });
32
+ if (res.status === 404) return null;
33
+ if (!res.ok) throw new Error(`Could not list the storage files: ${res.status} ${await res.text()}`);
34
+ const page = await res.json();
35
+ if (!page.storage) return { storage: false, files: {} };
36
+ Object.assign(files, page.files || {});
37
+ if (!page.next) return { storage: true, files };
38
+ after = page.next;
39
+ }
40
+ }
41
+
42
+ // One request to the bucket, IPv4, failing on IDLE_MS without progress.
43
+ // Resolves { status, headers, stream } once headers arrive.
44
+ function request(method, url, body) {
45
+ return new Promise((resolve, reject) => {
46
+ const u = new URL(url);
47
+ const mod = u.protocol === 'http:' ? http : https;
48
+ const req = mod.request({
49
+ method, hostname: u.hostname, port: u.port || undefined, path: u.pathname + u.search, family: 4,
50
+ headers: body ? { 'content-length': body.length } : {}
51
+ }, (resp) => {
52
+ resp.setTimeout(IDLE_MS, () => resp.destroy(new Error('the storage stopped sending')));
53
+ resolve({ status: resp.statusCode, headers: resp.headers, stream: resp });
54
+ });
55
+ req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
56
+ req.on('error', reject);
57
+ req.end(body || undefined);
58
+ });
59
+ }
60
+
61
+ async function retry(what, fn) {
62
+ let last;
63
+ for (let i = 0; i < TRIES; i++) {
64
+ try { return await fn(); } catch (e) { last = e; }
65
+ await new Promise((r) => setTimeout(r, 500 * (i + 1)));
66
+ }
67
+ throw new Error(`${what}: ${last.message}`);
68
+ }
69
+
70
+ // Runs fn over items, `n` at a time.
71
+ async function pool(items, n, fn) {
72
+ let i = 0;
73
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
74
+ while (i < items.length) await fn(items[i++]);
75
+ }));
76
+ }
77
+
78
+ // Signed links for many keys, 500 per request.
79
+ async function links(API, authHeaders, keys) {
80
+ const out = {};
81
+ for (let i = 0; i < keys.length; i += 500) {
82
+ const res = await fetch(`${API}/storage-links`, {
83
+ method: 'POST',
84
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
85
+ body: JSON.stringify({ keys: keys.slice(i, i + 500) })
86
+ });
87
+ if (!res.ok) throw new Error(`Could not get download links: ${res.status} ${await res.text()}`);
88
+ Object.assign(out, (await res.json()).links);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Which bucket files differ from the local copy under dest/server/files: by
94
+ // size, or by an mtime more than a second apart (a download sets the local
95
+ // mtime to the bucket's).
96
+ function changedRemote(dest, files) {
97
+ return Object.keys(files).filter((key) => {
98
+ const local = join(dest, FILES_DIR, ...key.split('/'));
99
+ let st;
100
+ try { st = fs.statSync(local); } catch { return true; }
101
+ const [size, mtime] = files[key];
102
+ return st.size !== size || Math.abs(Math.floor(st.mtimeMs / 1000) - mtime) > 1;
103
+ });
104
+ }
105
+
106
+ // Download `keys` into dest/server/files. Each file is written to a .part
107
+ // beside it and renamed when complete, so an interrupted run leaves no torn
108
+ // file. counter() is a pass-through that counts bytes for the progress line.
109
+ async function download(API, authHeaders, dest, files, keys, counter) {
110
+ const failed = [];
111
+ for (let i = 0; i < keys.length; i += 500) {
112
+ const batch = keys.slice(i, i + 500);
113
+ const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
114
+ await pool(batch, PARALLEL, async (key) => {
115
+ const target = join(dest, FILES_DIR, ...key.split('/'));
116
+ if (!target.startsWith(join(dest, FILES_DIR) + sep)) return;
117
+ fs.mkdirSync(dirname(target), { recursive: true });
118
+ const part = target + '.part';
119
+ try {
120
+ await retry(key, async () => {
121
+ const r = await request('GET', urls[key]);
122
+ if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
123
+ await pipeline(r.stream, counter(), fs.createWriteStream(part));
124
+ });
125
+ fs.renameSync(part, target);
126
+ const mtime = files[key][1];
127
+ if (mtime) fs.utimesSync(target, mtime, mtime);
128
+ } catch (e) {
129
+ try { fs.unlinkSync(part); } catch {}
130
+ failed.push(e.message);
131
+ }
132
+ });
133
+ }
134
+ return failed;
135
+ }
136
+
137
+ // The local files a restore would send: everything under root/server/files
138
+ // except names the engine reserves ("$...", its index and spool) and partial
139
+ // downloads, as { key: [size, mtime] }.
140
+ function localFiles(root) {
141
+ const base = join(root, FILES_DIR);
142
+ const out = {};
143
+ const walk = (dir) => {
144
+ let ents;
145
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
146
+ for (const e of ents) {
147
+ if (e.name.startsWith('$') || e.name.endsWith('.part') || e.name.endsWith('.tmp')) continue;
148
+ const full = join(dir, e.name);
149
+ if (e.isDirectory()) walk(full);
150
+ else if (e.isFile()) {
151
+ const st = fs.statSync(full);
152
+ out[relative(base, full).split(sep).join('/')] = [st.size, Math.floor(st.mtimeMs / 1000)];
153
+ }
154
+ }
155
+ };
156
+ walk(base);
157
+ return out;
158
+ }
159
+
160
+ // What a restore uploads: local files the bucket lacks or holds at another
161
+ // size, or holds older than the local copy. Nothing is ever deleted.
162
+ function changedLocal(local, remote) {
163
+ return Object.keys(local).filter((key) => {
164
+ const r = remote[key];
165
+ if (!r) return true;
166
+ return r[0] !== local[key][0] || local[key][1] > r[1] + 1;
167
+ });
168
+ }
169
+
170
+ async function postJson(API, authHeaders, path, body) {
171
+ const res = await fetch(`${API}${path}`, {
172
+ method: 'POST',
173
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
174
+ body: JSON.stringify(body)
175
+ });
176
+ if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
177
+ return res.json();
178
+ }
179
+
180
+ // Send each key from root/server/files to the bucket. Returns the failures.
181
+ async function upload(API, authHeaders, root, local, keys, onBytes) {
182
+ const failed = [];
183
+ await pool(keys, 4, async (key) => {
184
+ const file = join(root, FILES_DIR, ...key.split('/'));
185
+ const size = local[key][0];
186
+ try {
187
+ const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
188
+ if (plan.url) {
189
+ const body = fs.readFileSync(file);
190
+ await retry(key, async () => {
191
+ const r = await request('PUT', plan.url, body);
192
+ r.stream.resume();
193
+ if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
194
+ });
195
+ onBytes(size);
196
+ return;
197
+ }
198
+ const fd = fs.openSync(file, 'r');
199
+ try {
200
+ const etags = new Array(plan.parts.length);
201
+ await pool(plan.parts.map((url, i) => ({ url, i })), 4, async ({ url, i }) => {
202
+ const len = Math.min(plan.partSize, size - i * plan.partSize);
203
+ const buf = Buffer.alloc(len);
204
+ fs.readSync(fd, buf, 0, len, i * plan.partSize);
205
+ await retry(`${key} part ${i + 1}`, async () => {
206
+ const r = await request('PUT', url, buf);
207
+ r.stream.resume();
208
+ if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
209
+ etags[i] = r.headers.etag;
210
+ });
211
+ onBytes(len);
212
+ });
213
+ await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
214
+ } catch (e) {
215
+ await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
216
+ throw e;
217
+ } finally {
218
+ fs.closeSync(fd);
219
+ }
220
+ } catch (e) {
221
+ failed.push(`${key}: ${e.message}`);
222
+ }
223
+ });
224
+ return failed;
225
+ }
226
+
227
+ module.exports = { FILES_DIR, manifest, changedRemote, download, localFiles, changedLocal, upload };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"