querysub 0.528.0 → 0.529.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.528.0",
3
+ "version": "0.529.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -70,8 +70,8 @@
70
70
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
71
71
  "pako": "^2.1.0",
72
72
  "peggy": "^5.0.6",
73
- "sliftutils": "^1.7.5",
74
- "socket-function": "^1.2.16",
73
+ "sliftutils": "^1.7.13",
74
+ "socket-function": "^1.2.21",
75
75
  "terser": "^5.31.0",
76
76
  "typenode": "^6.6.1",
77
77
  "typesafecss": "^0.32.0",
@@ -1,17 +1,12 @@
1
- import { cache, lazy } from "socket-function/src/caching";
1
+ import { cache } from "socket-function/src/caching";
2
2
  import { getStorageDir } from "../fs";
3
3
  import { Archives } from "./archives";
4
4
  import fs from "fs";
5
5
  import os from "os";
6
- import { isNode, sort, timeInHour, timeInMinute } from "socket-function/src/misc";
7
- import { httpsRequest } from "../https";
8
- import { delay } from "socket-function/src/batching";
9
- import { devDebugbreak, isLogBackblaze } from "../config";
10
- import { formatNumber, formatTime } from "socket-function/src/formatting/format";
11
- import { blue, green, magenta } from "socket-function/src/formatting/logColors";
12
- import debugbreak from "debugbreak";
13
- import { onTimeProfile } from "../-0-hooks/hooks";
14
- import dns from "dns";
6
+ import { isNode, timeInMinute } from "socket-function/src/misc";
7
+ import { isLogBackblaze } from "../config";
8
+ import { ArchivesBackblaze as ArchivesBackblazeBase } from "sliftutils/storage/backblaze";
9
+ import type { IArchives, ArchivesConfig } from "sliftutils/storage/IArchives";
15
10
 
16
11
  export function hasBackblazePermissions() {
17
12
  return isNode() && fs.existsSync(getBackblazePath());
@@ -29,913 +24,64 @@ export function getBackblazePath() {
29
24
  return testPaths[0];
30
25
  }
31
26
 
32
- type BackblazeCreds = {
33
- applicationKeyId: string;
34
- applicationKey: string;
35
- };
36
-
37
- let backblazeCreds = lazy((): BackblazeCreds => (
38
- JSON.parse(fs.readFileSync(getBackblazePath(), "utf8")) as {
39
- applicationKeyId: string;
40
- applicationKey: string;
41
- }
42
- ));
43
- const getAPI = lazy(async () => {
44
- let creds = backblazeCreds();
45
-
46
- // NOTE: On errors, our retry code resets this lazy, so we DO get new authorize when needed.
47
- // TODO: Maybe we should get new authorization periodically at well?
48
- let authorizeRaw = await httpsRequest("https://api.backblazeb2.com/b2api/v2/b2_authorize_account", undefined, "GET", undefined, {
49
- headers: {
50
- Authorization: "Basic " + Buffer.from(creds.applicationKeyId + ":" + creds.applicationKey).toString("base64"),
51
- }
52
- });
53
-
54
- let auth = JSON.parse(authorizeRaw.toString()) as {
55
- accountId: string;
56
- authorizationToken: string;
57
- apiUrl: string;
58
- downloadUrl: string;
59
- allowed: {
60
- bucketId: string;
61
- bucketName: string;
62
- capabilities: string[];
63
- namePrefix: string;
64
- }[];
65
- };
66
-
67
- function createB2Function<Arg, Result>(name: string, type: "POST" | "GET", noAccountId?: "noAccountId"): (arg: Arg) => Promise<Result> {
68
- return async (arg: Arg) => {
69
- if (!noAccountId) {
70
- arg = { accountId: auth.accountId, ...arg };
71
- }
72
- try {
73
- let url = auth.apiUrl + "/b2api/v2/" + name;
74
- let time = Date.now();
75
- let result = await httpsRequest(url, Buffer.from(JSON.stringify(arg)), type, undefined, {
76
- headers: {
77
- Authorization: auth.authorizationToken,
78
- }
79
- });
80
- onTimeProfile("Backblaze API", time);
81
- return JSON.parse(result.toString());
82
- } catch (e: any) {
83
- throw new Error(`Error in ${name}, arg ${JSON.stringify(arg).slice(0, 1000)}: ${e.stack}`);
84
- }
85
- };
86
- }
87
-
88
- const createBucket = createB2Function<{
89
- bucketName: string;
90
- bucketType: "allPrivate" | "allPublic";
91
- lifecycleRules?: any[];
92
- corsRules?: unknown[];
93
- bucketInfo?: {
94
- [key: string]: unknown;
95
- };
96
- }, {
97
- accountId: string;
98
- bucketId: string;
99
- bucketName: string;
100
- bucketType: "allPrivate" | "allPublic";
101
- bucketInfo: {
102
- lifecycleRules: any[];
103
- };
104
- corsRules: any[];
105
- lifecycleRules: any[];
106
- revision: number;
107
- }>("b2_create_bucket", "POST");
108
-
109
- const updateBucket = createB2Function<{
110
- accountId: string;
111
- bucketId: string;
112
- bucketType?: "allPrivate" | "allPublic";
113
- lifecycleRules?: any[];
114
- bucketInfo?: {
115
- [key: string]: unknown;
116
- };
117
- corsRules?: unknown[];
118
- }, {
119
- accountId: string;
120
- bucketId: string;
121
- bucketName: string;
122
- bucketType: "allPrivate" | "allPublic";
123
- bucketInfo: {
124
- lifecycleRules: any[];
125
- };
126
- corsRules: any[];
127
- lifecycleRules: any[];
128
- revision: number;
129
- }>("b2_update_bucket", "POST");
130
-
131
- // https://www.backblaze.com/apidocs/b2-update-bucket
132
- // TODO: b2_update_bucket, so we can update CORS, etc
133
-
134
- const listBuckets = createB2Function<{
135
- bucketName?: string;
136
- }, {
137
- buckets: {
138
- accountId: string;
139
- bucketId: string;
140
- bucketName: string;
141
- bucketType: "allPrivate" | "allPublic";
142
- bucketInfo: {
143
- lifecycleRules: any[];
144
- };
145
- corsRules: any[];
146
- lifecycleRules: any[];
147
- revision: number;
148
- }[];
149
- }>("b2_list_buckets", "POST");
150
-
151
- function encodePath(path: string) {
152
- // Preserve slashes, but encode everything else
153
- path = path.split("/").map(encodeURIComponent).join("/");
154
- if (path.startsWith("/")) path = "%2F" + path.slice(1);
155
- if (path.endsWith("/")) path = path.slice(0, -1) + "%2F";
156
- // NOTE: For some reason, this won't render in the web UI correctly. BUT, it'll
157
- // work get get/set and find
158
- // - ALSO, it seems to add duplicate files? This might also be a web UI thing. It
159
- // seems to work though.
160
- while (path.includes("//")) {
161
- path = path.replaceAll("//", "/%2F");
162
- }
163
- return path;
164
- }
165
-
166
- async function downloadFileByName(config: {
167
- bucketName: string;
168
- fileName: string;
169
- range?: { start: number; end: number; };
170
- }) {
171
- let fileName = encodePath(config.fileName);
172
-
173
- let result = await httpsRequest(auth.apiUrl + "/file/" + config.bucketName + "/" + fileName, Buffer.from(JSON.stringify({
174
- accountId: auth.accountId,
175
- responseType: "arraybuffer",
176
- })), "GET", undefined, {
177
- headers: Object.fromEntries(Object.entries({
178
- Authorization: auth.authorizationToken,
179
- "Content-Type": "application/json",
180
- Range: config.range ? `bytes=${config.range.start}-${config.range.end - 1}` : undefined,
181
- }).filter(x => x[1] !== undefined)),
182
- });
183
- return result;
184
- }
185
-
186
- // Oh... apparently, we can't reuse these? Huh...
187
- const getUploadURL = (async (bucketId: string) => {
188
- //setTimeout(() => getUploadURL.clear(bucketId), timeInHour * 1);
189
- let getUploadUrlRaw = await httpsRequest(auth.apiUrl + "/b2api/v2/b2_get_upload_url?bucketId=" + bucketId, undefined, "GET", undefined, {
190
- headers: {
191
- Authorization: auth.authorizationToken,
192
- }
193
- });
194
-
195
- return JSON.parse(getUploadUrlRaw.toString()) as {
196
- bucketId: string;
197
- uploadUrl: string;
198
- authorizationToken: string;
199
- };
200
- });
201
-
202
- async function uploadFile(config: {
203
- bucketId: string;
204
- fileName: string;
205
- data: Buffer;
206
- }) {
207
- let getUploadUrl = await getUploadURL(config.bucketId);
208
-
209
- await httpsRequest(getUploadUrl.uploadUrl, config.data, "POST", undefined, {
210
- headers: {
211
- Authorization: getUploadUrl.authorizationToken,
212
- "X-Bz-File-Name": encodePath(config.fileName),
213
- "Content-Type": "b2/x-auto",
214
- "X-Bz-Content-Sha1": "do_not_verify",
215
- "Content-Length": config.data.length + "",
216
- }
217
- });
218
- }
219
-
220
- const hideFile = createB2Function<{
221
- bucketId: string;
222
- fileName: string;
223
- }, {}>("b2_hide_file", "POST", "noAccountId");
224
-
225
- const getFileInfo = createB2Function<{
226
- bucketName: string;
227
- fileId: string;
228
- }, {
229
- fileId: string;
230
- fileName: string;
231
- accountId: string;
232
- bucketId: string;
233
- contentLength: number;
234
- contentSha1: string;
235
- contentType: string;
236
- fileInfo: {
237
- src_last_modified_millis: number;
238
- };
239
- action: string;
240
- uploadTimestamp: number;
241
- }>("b2_get_file_info", "POST", "noAccountId");
242
-
243
- const listFileNames = createB2Function<{
244
- bucketId: string;
245
- prefix: string;
246
- startFileName?: string;
247
- maxFileCount?: number;
248
- delimiter?: string;
249
- }, {
250
- files: {
251
- fileId: string;
252
- fileName: string;
253
- accountId: string;
254
- bucketId: string;
255
- contentLength: number;
256
- contentSha1: string;
257
- contentType: string;
258
- fileInfo: {
259
- src_last_modified_millis: number;
260
- };
261
- action: string;
262
- uploadTimestamp: number;
263
- }[];
264
- nextFileName: string;
265
- }>("b2_list_file_names", "POST", "noAccountId");
266
-
267
- const copyFile = createB2Function<{
268
- sourceFileId: string;
269
- fileName: string;
270
- destinationBucketId: string;
271
- }, {}>("b2_copy_file", "POST", "noAccountId");
272
-
273
- const startLargeFile = createB2Function<{
274
- bucketId: string;
275
- fileName: string;
276
- contentType: string;
277
- fileInfo: { [key: string]: string };
278
- }, {
279
- fileId: string;
280
- fileName: string;
281
- accountId: string;
282
- bucketId: string;
283
- contentType: string;
284
- fileInfo: any;
285
- uploadTimestamp: number;
286
- }>("b2_start_large_file", "POST", "noAccountId");
287
-
288
- // Apparently we can't reuse these?
289
- const getUploadPartURL = (async (fileId: string) => {
290
- let uploadPartRaw = await httpsRequest(auth.apiUrl + "/b2api/v2/b2_get_upload_part_url?fileId=" + fileId, undefined, "GET", undefined, {
291
- headers: {
292
- Authorization: auth.authorizationToken,
293
- }
294
- });
295
- return JSON.parse(uploadPartRaw.toString()) as {
296
- fileId: string;
297
- partNumber: number;
298
- uploadUrl: string;
299
- authorizationToken: string;
300
- };
301
- });
302
- async function uploadPart(config: {
303
- fileId: string;
304
- partNumber: number;
305
- data: Buffer;
306
- sha1: string;
307
- }): Promise<{
308
- fileId: string;
309
- partNumber: number;
310
- contentLength: number;
311
- contentSha1: string;
312
- }> {
313
- let uploadPart = await getUploadPartURL(config.fileId);
314
-
315
- let result = await httpsRequest(uploadPart.uploadUrl, config.data, "POST", undefined, {
316
- headers: {
317
- Authorization: uploadPart.authorizationToken,
318
- "X-Bz-Part-Number": config.partNumber + "",
319
- "X-Bz-Content-Sha1": config.sha1,
320
- "Content-Length": config.data.length + "",
321
-
322
- }
323
- });
324
- return JSON.parse(result.toString());
325
- }
326
-
327
- const finishLargeFile = createB2Function<{
328
- fileId: string;
329
- partSha1Array: string[];
330
- }, {
331
- fileId: string;
332
- fileName: string;
333
- accountId: string;
334
- bucketId: string;
335
- contentLength: number;
336
- contentSha1: string;
337
- contentType: string;
338
- fileInfo: any;
339
- uploadTimestamp: number;
340
- }>("b2_finish_large_file", "POST", "noAccountId");
341
-
342
- const cancelLargeFile = createB2Function<{
343
- fileId: string;
344
- }, {}>("b2_cancel_large_file", "POST", "noAccountId");
345
-
346
- const getDownloadAuthorization = createB2Function<{
347
- bucketId: string;
348
- fileNamePrefix: string;
349
- validDurationInSeconds: number;
350
- b2ContentDisposition?: string;
351
- b2ContentLanguage?: string;
352
- b2Expires?: string;
353
- b2CacheControl?: string;
354
- b2ContentEncoding?: string;
355
- b2ContentType?: string;
356
- }, {
357
- bucketId: string;
358
- fileNamePrefix: string;
359
- authorizationToken: string;
360
- }>("b2_get_download_authorization", "POST", "noAccountId");
361
-
362
- async function getDownloadURL(path: string) {
363
- if (!path.startsWith("/")) {
364
- path = "/" + path;
365
- }
366
- return auth.downloadUrl + path;
367
- }
368
-
369
-
370
- return {
371
- createBucket,
372
- updateBucket,
373
- listBuckets,
374
- downloadFileByName,
375
- uploadFile,
376
- hideFile,
377
- getFileInfo,
378
- listFileNames,
379
- copyFile,
380
- startLargeFile,
381
- uploadPart,
382
- finishLargeFile,
383
- cancelLargeFile,
384
- getDownloadAuthorization,
385
- getDownloadURL,
386
- apiUrl: auth.apiUrl,
387
- };
388
- });
389
-
390
- type B2Api = (typeof getAPI) extends () => Promise<infer T> ? T : never;
391
-
392
-
27
+ // The implementation lives in sliftutils (it reads credentials via getSecret, which our appSecrets.ts serves from getBackblazePath). We forward to an inner instance instead of subclassing, because our Archives interface's move/copy take a querysub Archives target (with getBaseArchives unwrapping) which is type-incompatible with the base class's IArchives signatures.
393
28
  export class ArchivesBackblaze {
394
- public constructor(private config: {
29
+ private archives: ArchivesBackblazeBase;
30
+
31
+ public constructor(config: {
395
32
  bucketName: string;
396
33
  public?: boolean;
397
34
  immutable?: boolean;
398
35
  cacheTime?: number;
399
36
  allowedOrigins?: string[];
400
37
  }) {
401
- // Get the api, to setup cors
402
- void this.getBucketAPI();
38
+ this.archives = new ArchivesBackblazeBase(config);
39
+ if (isLogBackblaze()) {
40
+ this.archives.enableLogging();
41
+ }
403
42
  }
404
43
 
405
- private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
406
- private bucketId = "";
407
-
408
- private logging = isLogBackblaze();
409
- public enableLogging() {
410
- this.logging = true;
44
+ public enableLogging(): void {
45
+ this.archives.enableLogging();
411
46
  }
412
- private log(text: string) {
413
- if (!this.logging) return;
414
- console.log(text);
47
+ public getDebugName(): string {
48
+ return this.archives.getDebugName();
415
49
  }
416
-
417
- public getDebugName() {
418
- return "backblaze/" + this.config.bucketName;
50
+ public async hasWriteAccess(): Promise<boolean> {
51
+ return this.archives.hasWriteAccess();
419
52
  }
420
-
421
- private getBucketAPI = lazy(async () => {
422
- let api = await getAPI();
423
-
424
- let cacheTime = this.config.cacheTime ?? 0;
425
- if (this.config.immutable) {
426
- cacheTime = 86400 * 1000;
427
- }
428
-
429
- // ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
430
- let desiredCorsRules = [{
431
- corsRuleName: "allowAll",
432
- allowedOrigins: this.config.allowedOrigins ?? ["https"],
433
- allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
434
- allowedHeaders: ["range"],
435
- exposeHeaders: ["x-bz-content-sha1"],
436
- maxAgeSeconds: cacheTime / 1000,
437
- }];
438
- let bucketInfo: Record<string, unknown> = {};
439
- if (cacheTime) {
440
- bucketInfo["cache-control"] = `max-age=${cacheTime / 1000}`;
441
- }
442
-
443
-
444
- let exists = false;
445
- let retries = 0;
446
- while (true) {
447
- try {
448
- await api.createBucket({
449
- bucketName: this.bucketName,
450
- bucketType: this.config.public ? "allPublic" : "allPrivate",
451
- lifecycleRules: [{
452
- "daysFromUploadingToHiding": null,
453
- // Keep files for 7 days, which should be enough time to recover accidental hiding.
454
- "daysFromHidingToDeleting": 7,
455
- "fileNamePrefix": ""
456
- }],
457
- corsRules: desiredCorsRules,
458
- bucketInfo
459
- });
460
- } catch (e: any) {
461
- if (!e.stack.includes(`"duplicate_bucket_name"`)) {
462
- if (retries < 3) {
463
- console.error(`Backblaze create bucket failed, retrying in 5s: ${e.stack}`);
464
- await delay(5000);
465
- retries++;
466
- continue;
467
- }
468
- throw e;
469
- }
470
- exists = true;
471
- }
472
- break;
473
- }
474
-
475
- let bucketList = await api.listBuckets({
476
- bucketName: this.bucketName,
477
- });
478
- if (bucketList.buckets.length === 0) {
479
- throw new Error(`Bucket name "${this.bucketName}" is being used by someone else. Bucket names have to be globally unique. Try a different name until you find a free one.`);
480
- }
481
- this.bucketId = bucketList.buckets[0].bucketId;
482
-
483
- if (exists) {
484
- let bucket = bucketList.buckets[0];
485
- function normalize(obj: Record<string, unknown>) {
486
- let kvps = Object.entries(obj);
487
- sort(kvps, x => x[0]);
488
- return Object.fromEntries(kvps);
489
- }
490
- function orderIndependentEqual(lhs: Record<string, unknown>, rhs: Record<string, unknown>) {
491
- return JSON.stringify(normalize(lhs)) === JSON.stringify(normalize(rhs));
492
- }
493
- function orderIndependentEqualArray(lhs: unknown[], rhs: unknown[]) {
494
- if (lhs.length !== rhs.length) return false;
495
- for (let i = 0; i < lhs.length; i++) {
496
- if (!orderIndependentEqual(lhs[i] as Record<string, unknown>, rhs[i] as Record<string, unknown>)) return false;
497
- }
498
- return true;
499
- }
500
- if (
501
- !orderIndependentEqualArray(bucket.corsRules, desiredCorsRules)
502
- || !orderIndependentEqual(bucket.bucketInfo, bucketInfo)
503
- ) {
504
- console.log(magenta(`Updating CORS rules for ${this.bucketName}`), bucket.corsRules, desiredCorsRules);
505
- await api.updateBucket({
506
- accountId: bucket.accountId,
507
- bucketId: bucket.bucketId,
508
- bucketType: bucket.bucketType,
509
- lifecycleRules: bucket.lifecycleRules,
510
- corsRules: desiredCorsRules,
511
- bucketInfo: bucketInfo,
512
- });
513
- }
514
- }
515
- return api;
516
- });
517
-
518
- private currentReset: Promise<void> | undefined;
519
-
520
- // Keep track of when we last reset because of a 503
521
- private last503Reset = 0;
522
- // IMPORTANT! We must always CATCH AROUND the apiRetryLogic, NEVER inside of fnc. Otherwise we won't
523
- // be able to recreate the auth token.
524
- // `context` is a short label (verb + file path) included in every retry/error log so a stuck
525
- // silent-retry loop is identifiable from the logs.
526
- private async apiRetryLogic<T>(
527
- context: string,
528
- fnc: (api: B2Api) => Promise<T>,
529
- retries = 3
530
- ): Promise<T> {
531
- let api: B2Api | undefined;
532
- try {
533
- api = await this.getBucketAPI();
534
- return await fnc(api);
535
- } catch (err: any) {
536
- if (retries <= 0) throw err;
537
-
538
- // If it's a 503 and it's been a minute since we last reset, then Wait and reset.
539
- if (
540
- (err.stack.includes(`"status": 503`)
541
- || err.stack.includes(`"service_unavailable"`)
542
- || err.stack.includes(`"internal_error"`)
543
- || err.stack.includes(`ENOBUFS`)
544
- ) && Date.now() - this.last503Reset > 60 * 1000) {
545
- // Backblades is so flaky that we're just going to warn here.
546
- console.warn(`[${context}] Backblaze error, waiting and resetting: ${err.message}`);
547
- this.log(`[${context}] Backblaze error, waiting and resetting: ${err.message}`);
548
- this.currentReset = this.currentReset || (async () => {
549
- await delay(10 * 1000);
550
- // We check again in case, and in the very likely case that this is being run in parallel, we only want to reset once.
551
- if (Date.now() - this.last503Reset > 60 * 1000) {
552
- this.log(`[${context}] Resetting getAPI and getBucketAPI: ${err.message}`);
553
- this.last503Reset = Date.now();
554
- getAPI.reset();
555
- this.getBucketAPI.reset();
556
- }
557
- })().finally(() => this.currentReset = undefined);
558
- await this.currentReset;
559
- return this.apiRetryLogic(context, fnc, retries - 1);
560
- }
561
-
562
- // If the error is that the authorization token is invalid, reset getBucketAPI and getAPI
563
- // If the error is that the bucket isn't found, reset getBucketAPI
564
- if (err.stack.includes(`"expired_auth_token"`)) {
565
- this.log(`[${context}] Authorization token expired`);
566
- getAPI.reset();
567
- this.getBucketAPI.reset();
568
- return this.apiRetryLogic(context, fnc, retries - 1);
569
- }
570
-
571
- if (
572
- err.stack.includes(`no tomes available`)
573
- || err.stack.includes(`ETIMEDOUT`)
574
- || err.stack.includes(`socket hang up`)
575
- // Eh... this might be bad, but... I think we just get random 400 errors. If this spams errors,
576
- // we can remove this line.
577
- || err.stack.includes(`400 Bad Request`)
578
- || err.stack.includes(`getaddrinfo ENOTFOUND`)
579
- || err.stack.includes(`ECONNRESET`)
580
- || err.stack.includes(`ECONNREFUSED`)
581
- || err.stack.includes(`ENOBUFS`)
582
- ) {
583
- console.warn(`[${context}] Retrying in 5s: ${err.message}`);
584
- this.log(`[${context}] ${err.message} retrying in 5s`);
585
- await delay(5000);
586
- return this.apiRetryLogic(context, fnc, retries - 1);
587
- }
588
-
589
- if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
590
- if (api) {
591
- let urlObj = new URL(api.apiUrl);
592
- let hostname = urlObj.hostname;
593
- let lookupAddresses = await new Promise(resolve => {
594
- dns.lookup(hostname, (err, addresses) => {
595
- resolve(addresses);
596
- });
597
- });
598
- let resolveAddresses = await new Promise(resolve => {
599
- dns.resolve4(hostname, (err, addresses) => {
600
- resolve(addresses);
601
- });
602
- });
603
- console.error(`[${context}] getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
604
- }
605
- }
606
-
607
- // NOTE: The AI thought case that happens when we run out of retries, that's stupid. This obviously isn't the case. This is the case when it's a normal error, as in the file doesn't exist, we need to throw. We absolutely should not warn here. Warning here wouldn't be anything. It would just be saying, oh, we checked if a file and it didn't, which is normal, which is why we check if a file exists.
608
-
609
- throw err;
610
- }
53
+ public async getConfig(): Promise<ArchivesConfig> {
54
+ return this.archives.getConfig();
611
55
  }
612
-
613
56
  public async get(fileName: string, config?: { range?: { start: number; end: number; }; retryCount?: number }): Promise<Buffer | undefined> {
614
- let downloading = true;
615
- try {
616
- let time = Date.now();
617
- const downloadPoll = () => {
618
- if (!downloading) return;
619
- this.log(`Backblaze download in progress ${fileName}`);
620
- setTimeout(downloadPoll, 5000);
621
- };
622
- setTimeout(downloadPoll, 5000);
623
- let result = await this.apiRetryLogic(`get ${fileName}`, async (api) => {
624
- let range = config?.range;
625
- if (range) {
626
- let fileInfo = await this.getInfo(fileName);
627
- if (!fileInfo) throw new Error(`File ${fileName} not found`);
628
- let rangeStart = range.start;
629
- let rangeEnd = Math.min(range.end, fileInfo.size);
630
- // NOTE: I think if we request nothing, it confuses Backblaze and ends up giving us the entire file.
631
- if (rangeEnd <= rangeStart) return Buffer.alloc(0);
632
- let result = await api.downloadFileByName({
633
- bucketName: this.bucketName,
634
- fileName,
635
- range: { start: rangeStart, end: rangeEnd },
636
- });
637
- if (result.length !== rangeEnd - rangeStart) {
638
- let afterLength = await this.getInfo(fileName);
639
- if (afterLength && afterLength.size >= fileInfo.size) {
640
- console.error(`Backblaze range download return the correct number of bytes. Tried to get ${rangeStart}-${rangeEnd}, but received ${rangeStart}-${rangeStart + result.length}. For file: ${fileName}`);
641
- // I'm not sure if it's a bug that where we get extra data if we try to read beyond the end of the file, or if the bug is due to some kind of lag that will resolve itself if we wait a little bit.
642
- setTimeout(async () => {
643
- let resultAgain = await api.downloadFileByName({
644
- bucketName: this.bucketName,
645
- fileName,
646
- range: { start: rangeStart, end: rangeEnd },
647
- });
648
- devDebugbreak();
649
- let didResultFixItSelf = resultAgain.length === rangeEnd - rangeStart;
650
-
651
- console.log({ didResultFixItSelf }, resultAgain);
652
- }, timeInMinute * 2);
653
- }
654
- }
655
- }
656
- return await api.downloadFileByName({
657
- bucketName: this.bucketName,
658
- fileName,
659
- });
660
- });
661
- let timeStr = formatTime(Date.now() - time);
662
- let rateStr = formatNumber(result.length / (Date.now() - time) * 1000) + "B/s";
663
- this.log(`backblaze download (${formatNumber(result.length)}B${config?.range && `, ${formatNumber(config.range.start)} - ${formatNumber(config.range.end)}` || ""}) in ${timeStr} (${rateStr}, ${fileName})`);
664
- return result;
665
- } catch (e) {
666
- this.log(`backblaze file does not exist ${fileName}`);
667
- return undefined;
668
- } finally {
669
- downloading = false;
670
- }
57
+ return this.archives.get(fileName, config);
671
58
  }
672
- public async set(fileName: string, data: Buffer): Promise<void> {
673
- this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
674
- let f = fileName;
675
- await this.apiRetryLogic(`uploadFile ${fileName}`, async (api) => {
676
- await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, });
677
- });
678
- let existsChecks = 30;
679
- while (existsChecks > 0) {
680
- let exists = await this.getInfo(fileName);
681
- if (exists) break;
682
- await delay(1000);
683
- existsChecks--;
684
- }
685
- if (existsChecks === 0) {
686
- let exists = await this.getInfo(fileName);
687
- devDebugbreak();
688
- console.warn(`File ${fileName}/${f} was uploaded, but could not be found afterwards. Hopefully it was just deleted, very quickly? If backblaze is taking too long for files to propagate, then we might run into issues with the database atomicity.`);
689
- }
690
-
59
+ public async get2(fileName: string, config?: { range?: { start: number; end: number; } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
60
+ return this.archives.get2(fileName, config);
61
+ }
62
+ public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
63
+ return this.archives.set(fileName, data, config);
691
64
  }
692
65
  public async append(fileName: string, data: Buffer): Promise<void> {
693
66
  throw new Error(`ArchivesBackblaze does not support append. Use set instead.`);
694
- // this.log(`backblaze append (${formatNumber(data.length)}B) ${fileName}`);
695
- // // Backblaze doesn't have native append, so we need to get, concatenate, and set
696
- // let existing = await this.get(fileName);
697
- // let newData = existing ? Buffer.concat([existing, data]) : data;
698
- // await this.set(fileName, newData);
699
67
  }
700
68
  public async del(fileName: string): Promise<void> {
701
- this.log(`backblaze delete ${fileName}`);
702
- try {
703
- await this.apiRetryLogic(`hideFile ${fileName}`, async (api) => {
704
- await api.hideFile({ bucketId: this.bucketId, fileName: fileName });
705
- });
706
- } catch (e: any) {
707
- this.log(`backblaze error in hide, possibly already hidden ${fileName}\n${e.stack}`);
708
- }
709
-
710
- // NOTE: Deletion SEEMS to work. This DOES break if we delete a file which keeps being recreated,
711
- // ex, the heartbeat.
712
- // let existsChecks = 10;
713
- // while (existsChecks > 0) {
714
- // let exists = await this.getInfo(fileName);
715
- // if (!exists) break;
716
- // await delay(1000);
717
- // existsChecks--;
718
- // }
719
- // if (existsChecks === 0) {
720
- // let exists = await this.getInfo(fileName);
721
- // devDebugbreak();
722
- // console.warn(`File ${fileName} was deleted, but was still found afterwards`);
723
- // exists = await this.getInfo(fileName);
724
- // }
69
+ return this.archives.del(fileName);
725
70
  }
726
-
727
71
  public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined>; }): Promise<void> {
728
-
729
- let onError: (() => Promise<void>)[] = [];
730
- let time = Date.now();
731
- try {
732
- let { path } = config;
733
- // Backblaze requires 5MB chunks. But, larger is more efficient for us.
734
- const MIN_CHUNK_SIZE = 32 * 1024 * 1024;
735
- let dataQueue: Buffer[] = [];
736
- async function getNextData(): Promise<Buffer | undefined> {
737
- if (dataQueue.length) return dataQueue.shift();
738
- // Get buffers until we get 5MB, OR, end. Backblaze requires this for large files.
739
- let totalBytes = 0;
740
- let buffers: Buffer[] = [];
741
- while (totalBytes < MIN_CHUNK_SIZE) {
742
- let data = await config.getNextData();
743
- if (!data) break;
744
- totalBytes += data.length;
745
- buffers.push(data);
746
- }
747
- if (!buffers.length) return undefined;
748
- return Buffer.concat(buffers);
749
- }
750
-
751
- let fileName = path;
752
- let data = await getNextData();
753
- if (!data?.length) return;
754
- // Backblaze disallows overly small files
755
- if (data.length < MIN_CHUNK_SIZE) {
756
- return await this.set(fileName, data);
757
- }
758
- // Backblaze disallows less than 2 chunks
759
- let secondData = await getNextData();
760
- if (!secondData?.length) {
761
- return await this.set(fileName, data);
762
- }
763
- // ALSO, if there are two chunks, but one is too small, combine it. This helps allow us never
764
- // send small chunks.
765
- if (secondData.length < MIN_CHUNK_SIZE) {
766
- return await this.set(fileName, Buffer.concat([data, secondData]));
767
- }
768
- this.log(`Uploading large file ${config.path}`);
769
- dataQueue.unshift(data, secondData);
770
-
771
-
772
- let uploadInfo = await this.apiRetryLogic(`startLargeFile ${fileName}`, async (api) => {
773
- return await api.startLargeFile({
774
- bucketId: this.bucketId,
775
- fileName: fileName,
776
- contentType: "b2/x-auto",
777
- fileInfo: {},
778
- });
779
- });
780
- onError.push(async () => {
781
- await this.apiRetryLogic(`cancelLargeFile ${fileName}`, async (api) => {
782
- await api.cancelLargeFile({ fileId: uploadInfo.fileId });
783
- });
784
- });
785
-
786
- const LOG_INTERVAL = timeInMinute;
787
- let nextLogTime = Date.now() + LOG_INTERVAL;
788
-
789
- let partNumber = 1;
790
- let partSha1Array: string[] = [];
791
- let totalBytes = 0;
792
- while (true) {
793
- data = await getNextData();
794
- if (!data) break;
795
- // So... if the next chunk is the last one, combine it with the current one. This
796
- // prevents ANY uploads from being < the threshold, as apparently the "last part"
797
- // check in backblaze fails when we have to retry an upload (due to "no tomes available").
798
- // Well it can't fail if even the last part is > 5MB, now can it!
799
- // BUT, only if this isn't the first chunk, otherwise we might try to send
800
- // a single chunk, which we can't do.
801
- if (partSha1Array.length > 0) {
802
- let maybeLastData = await getNextData();
803
- if (maybeLastData) {
804
- if (maybeLastData.length < MIN_CHUNK_SIZE) {
805
- // It's the last one, so consume it now
806
- data = Buffer.concat([data, maybeLastData]);
807
- } else {
808
- // It's not the last one. Put it back, in case the one AFTER is the last
809
- // one, in which case we need to merge maybeLastData with the next next data.
810
- dataQueue.unshift(maybeLastData);
811
- }
812
- }
813
- }
814
- let sha1 = require("crypto").createHash("sha1");
815
- sha1.update(data);
816
- let sha1Hex = sha1.digest("hex");
817
- partSha1Array.push(sha1Hex);
818
- await this.apiRetryLogic(`uploadPart#${partNumber} ${fileName}`, async (api) => {
819
- if (!data) throw new Error("Impossible, data is undefined");
820
-
821
- let timeStr = formatTime(Date.now() - time);
822
- let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
823
- this.log(`Uploading large file part ${partNumber}, uploaded ${blue(formatNumber(totalBytes) + "B")} in ${blue(timeStr)} (${blue(rateStr)}). ${config.path}`);
824
- totalBytes += data.length;
825
-
826
- await api.uploadPart({
827
- fileId: uploadInfo.fileId,
828
- partNumber: partNumber,
829
- data: data,
830
- sha1: sha1Hex,
831
- });
832
- });
833
- partNumber++;
834
-
835
- if (Date.now() > nextLogTime) {
836
- nextLogTime = Date.now() + LOG_INTERVAL;
837
- let timeStr = formatTime(Date.now() - time);
838
- let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
839
- console.log(`Still uploading large file at ${Date.now()}. Uploaded ${formatNumber(totalBytes)}B in ${timeStr} (${rateStr}). ${config.path}`);
840
- }
841
- }
842
- this.log(`Finished uploading large file uploaded ${green(formatNumber(totalBytes))}B`);
843
-
844
- await this.apiRetryLogic(`finishLargeFile ${fileName}`, async (api) => {
845
- await api.finishLargeFile({
846
- fileId: uploadInfo.fileId,
847
- partSha1Array: partSha1Array,
848
- });
849
- });
850
- } catch (e: any) {
851
- for (let c of onError) {
852
- try {
853
- await c();
854
- } catch (e) {
855
- console.error(`Error during error clean. Ignoring, we will rethrow the original error, path ${config.path}`, e);
856
- }
857
- }
858
-
859
- throw new Error(`Error in setLargeFile for ${config.path}: ${e.stack}`);
860
- }
72
+ return this.archives.setLargeFile(config);
861
73
  }
862
-
863
74
  public async getInfo(fileName: string): Promise<{ writeTime: number; size: number; } | undefined> {
864
- return await this.apiRetryLogic(`getInfo ${fileName}`, async (api) => {
865
- try {
866
- // NOTE: Apparently, there's no other way to do this, as the file name does not equal the file ID, and git file info requires the file ID.
867
- let info = await api.listFileNames({ bucketId: this.bucketId, prefix: fileName, maxFileCount: 10 });
868
- let file = info.files.find(x => x.fileName === fileName && x.action === "upload");
869
- if (!file) {
870
- this.log(`Backblaze file not exists ${fileName}`);
871
- return undefined;
872
- }
873
- this.log(`Backblaze file exists ${fileName}`);
874
- return {
875
- writeTime: file.uploadTimestamp,
876
- size: file.contentLength,
877
- };
878
- } catch (e: any) {
879
- if (e.stack.includes(`file_not_found`)) {
880
- this.log(`Backblaze file not exists ${fileName}`);
881
- return undefined;
882
- }
883
- throw e;
884
- }
885
- });
75
+ return this.archives.getInfo(fileName);
886
76
  }
887
-
888
- // For example findFileNames("ips/")
889
77
  public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
890
- let result = await this.findInfo(prefix, config);
891
- return result.map(x => x.path);
78
+ return this.archives.find(prefix, config);
892
79
  }
893
80
  public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<{ path: string; createTime: number; size: number; }[]> {
894
- return await this.apiRetryLogic(`findInfo ${prefix}`, async (api) => {
895
- if (!config?.shallow && config?.type === "folders") {
896
- let allFiles = await this.findInfo(prefix);
897
- let allFolders = new Map<string, { path: string; createTime: number; size: number }>();
898
- for (let { path, createTime, size } of allFiles) {
899
- let folder = path.split("/").slice(0, -1).join("/");
900
- if (!folder) continue;
901
- allFolders.set(folder, { path: folder, createTime, size });
902
- }
903
- return Array.from(allFolders.values());
904
- }
905
- let files = new Map<string, { path: string; createTime: number; size: number; }>();
906
- let startFileName = "";
907
- while (true) {
908
- let result = await api.listFileNames({
909
- bucketId: this.bucketId,
910
- prefix: prefix,
911
- startFileName,
912
- maxFileCount: 1000,
913
- delimiter: config?.shallow ? "/" : undefined,
914
- });
915
- for (let file of result.files) {
916
- if (file.action === "upload" && config?.type !== "folders") {
917
- files.set(file.fileName, { path: file.fileName, createTime: file.uploadTimestamp, size: file.contentLength });
918
- } else if (file.action === "folder" && config?.type === "folders") {
919
- let folder = file.fileName;
920
- if (folder.endsWith("/")) {
921
- folder = folder.slice(0, -1);
922
- }
923
- files.set(folder, { path: folder, createTime: file.uploadTimestamp, size: file.contentLength });
924
- }
925
-
926
- }
927
- startFileName = result.nextFileName;
928
- if (!startFileName) break;
929
- }
930
- return Array.from(files.values());
931
- });
81
+ return this.archives.findInfo(prefix, config);
932
82
  }
933
-
934
- public async assertPathValid(path: string) {
935
- let bytes = Buffer.from(path, "utf8");
936
- if (bytes.length > 1000) {
937
- throw new Error(`Path too long: ${path.length} characters > 1000 characters. Path: ${path}`);
938
- }
83
+ public async assertPathValid(path: string): Promise<void> {
84
+ return this.archives.assertPathValid(path);
939
85
  }
940
86
 
941
87
  public async move(config: {
@@ -943,49 +89,23 @@ export class ArchivesBackblaze {
943
89
  target: Archives;
944
90
  targetPath: string;
945
91
  copyInstead?: boolean;
946
- }) {
947
- let { path, target, targetPath } = config;
92
+ }): Promise<void> {
93
+ let { target, targetPath } = config;
948
94
  let base = target.getBaseArchives?.();
949
95
  if (base) {
950
96
  target = base.archives;
951
97
  targetPath = base.parentPath + targetPath;
952
98
  }
953
- // A self move should NOOP (and definitely not copy, and then delete itself!)
954
- if (target === this && path === targetPath) {
955
- this.log(`Backblaze move path to itself. Skipping move, as there is no work to do. ${path}`);
956
- return;
957
- }
958
- if (target instanceof ArchivesBackblaze) {
959
- let targetBucketId = target.bucketId;
960
- if (targetBucketId === this.bucketId && path === targetPath) return;
961
- await this.apiRetryLogic(`move ${path} -> ${targetPath}`, async (api) => {
962
- // Ugh... listing the file name sucks, but... I guess it's still better than
963
- // downloading and re-uploading the entire file.
964
- let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, maxFileCount: 10 });
965
- let file = info.files.find(x => x.fileName === path);
966
- if (!file) throw new Error(`File not found to move: ${path}`);
967
- await api.copyFile({
968
- sourceFileId: file.fileId,
969
- fileName: targetPath,
970
- destinationBucketId: targetBucketId,
971
- });
972
- });
973
- } else {
974
- let data = await this.get(path);
975
- if (!data) throw new Error(`File not found to move: ${path}`);
976
- await target.set(targetPath, data);
977
- }
978
-
979
- if (!config.copyInstead) {
980
- let exists = await this.getInfo(targetPath);
981
- if (!exists) {
982
- debugbreak(2);
983
- debugger;
984
- console.error(`File not found after move. Leaving BOTH files. ${targetPath} was not found. Being moved from ${path}`);
985
- } else {
986
- await this.del(path);
987
- }
988
- }
99
+ // Hand the inner instance across so the base implementation's bucket-to-bucket copyFile fast path (an instanceof check on its own class) still triggers.
100
+ let innerTarget: IArchives = target instanceof ArchivesBackblaze
101
+ ? target.archives
102
+ : target as unknown as IArchives;
103
+ return this.archives.move({
104
+ path: config.path,
105
+ target: innerTarget,
106
+ targetPath,
107
+ copyInstead: config.copyInstead,
108
+ });
989
109
  }
990
110
 
991
111
  public async copy(config: {
@@ -996,13 +116,8 @@ export class ArchivesBackblaze {
996
116
  return this.move({ ...config, copyInstead: true });
997
117
  }
998
118
 
999
- public async getURL(path: string) {
1000
- return await this.apiRetryLogic(`getURL ${path}`, async (api) => {
1001
- if (path.startsWith("/")) {
1002
- path = path.slice(1);
1003
- }
1004
- return await api.getDownloadURL("file/" + this.bucketName + "/" + path);
1005
- });
119
+ public async getURL(path: string): Promise<string> {
120
+ return this.archives.getURL(path);
1006
121
  }
1007
122
 
1008
123
  public async getDownloadAuthorization(config: {
@@ -1019,25 +134,10 @@ export class ArchivesBackblaze {
1019
134
  fileNamePrefix: string;
1020
135
  authorizationToken: string;
1021
136
  }> {
1022
- return await this.apiRetryLogic(`getDownloadAuthorization ${config.fileNamePrefix ?? ""}`, async (api) => {
1023
- return await api.getDownloadAuthorization({
1024
- bucketId: this.bucketId,
1025
- fileNamePrefix: config.fileNamePrefix ?? "",
1026
- ...config,
1027
- });
1028
- });
137
+ return this.archives.getDownloadAuthorization(config);
1029
138
  }
1030
139
  }
1031
140
 
1032
- /*
1033
- Names should be a UTF-8 string up to 1024 bytes with the following exceptions:
1034
- Character codes below 32 are not allowed.
1035
- DEL characters (127) are not allowed.
1036
- Backslashes are not allowed.
1037
- File names cannot start with /, end with /, or contain //.
1038
- */
1039
-
1040
-
1041
141
  export const getArchivesBackblaze = cache((domain: string) => {
1042
142
  return new ArchivesBackblaze({ bucketName: domain });
1043
143
  });
@@ -1066,4 +166,4 @@ export const getArchivesBackblazePublic = cache((domain: string) => {
1066
166
  cacheTime: timeInMinute,
1067
167
  allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
1068
168
  });
1069
- });
169
+ });
@@ -0,0 +1,17 @@
1
+ import type { qreact as qreactType } from "./qreact";
2
+
3
+ // qreact.tsx assigns itself onto globalThis at runtime, but only files that import { qreact } see it in the type system. Sliftutils' .tsx sources get pulled into our program (its .d.ts files' relative imports resolve to the neighboring sources) and compile under our qreact jsxFactory without importing it — this ambient declaration gives them the global qreact value and the global JSX fallback namespace.
4
+ declare global {
5
+ const qreact: typeof qreactType;
6
+ namespace JSX {
7
+ type IntrinsicElements = qreactType.JSX.IntrinsicElements;
8
+ type Element = qreactType.JSX.Element;
9
+ type IntrinsicClassAttributes<T = unknown> = qreactType.JSX.IntrinsicClassAttributes<T>;
10
+ interface ElementClass {
11
+ render: any;
12
+ }
13
+ interface ElementAttributesProperty {
14
+ props: {};
15
+ }
16
+ }
17
+ }
@@ -264,6 +264,33 @@ async function main() {
264
264
  console.log(`✅ Swap configured: ${swapCheck.split(/\s+/)[1]}MB total vs ${ramMB}MB REAL MEMORY`);
265
265
  }
266
266
 
267
+ // Enable TCP BBR congestion control (with fq qdisc, which BBR is designed to pair with)
268
+ console.log("Checking TCP congestion control...");
269
+ const currentCongestion = (await runPromise(`ssh ${sshRemote} "sysctl -n net.ipv4.tcp_congestion_control"`)).trim();
270
+ if (currentCongestion === "bbr") {
271
+ console.log("✅ TCP BBR already enabled");
272
+ } else {
273
+ await runPromise(`ssh ${sshRemote} "sudo modprobe tcp_bbr"`, { nothrow: true });
274
+ const availableCongestion = await runPromise(`ssh ${sshRemote} "sysctl -n net.ipv4.tcp_available_congestion_control"`);
275
+ if (!availableCongestion.split(/\s+/).includes("bbr")) {
276
+ console.warn(`⚠️ TCP BBR not available on this kernel (current: ${currentCongestion}, available: ${availableCongestion.trim()}). Skipping.`);
277
+ } else {
278
+ console.log(`Enabling TCP BBR (current: ${currentCongestion})...`);
279
+ // Persist across reboots (modules-load.d so tcp_bbr is loaded before sysctl.d is applied)
280
+ await runPromise(`ssh ${sshRemote} "echo 'tcp_bbr' | sudo tee /etc/modules-load.d/bbr.conf"`);
281
+ await runPromise(`ssh ${sshRemote} "printf 'net.core.default_qdisc=fq\\nnet.ipv4.tcp_congestion_control=bbr\\n' | sudo tee /etc/sysctl.d/99-bbr.conf"`);
282
+ // Enable immediately
283
+ await runPromise(`ssh ${sshRemote} "sudo sysctl -w net.core.default_qdisc=fq"`);
284
+ await runPromise(`ssh ${sshRemote} "sudo sysctl -w net.ipv4.tcp_congestion_control=bbr"`);
285
+ const newCongestion = (await runPromise(`ssh ${sshRemote} "sysctl -n net.ipv4.tcp_congestion_control"`)).trim();
286
+ if (newCongestion === "bbr") {
287
+ console.log("✅ TCP BBR enabled");
288
+ } else {
289
+ console.warn(`⚠️ Tried to enable TCP BBR, but congestion control is still: ${newCongestion}`);
290
+ }
291
+ }
292
+ }
293
+
267
294
  let backblazePath = getBackblazePath();
268
295
 
269
296
  console.log("Setting up machine:", sshRemote);
package/tsconfig.json CHANGED
@@ -24,6 +24,13 @@
24
24
  ],
25
25
  "socket-function/*": [
26
26
  "./node_modules/socket-function/*"
27
+ ],
28
+ // Resolve to the emitted declarations instead of the linked sources: sliftutils compiles its JSX with preact.createElement, so its .tsx files cannot compile under our qreact jsxFactory.
29
+ "sliftutils": [
30
+ "./node_modules/sliftutils/index.d.ts"
31
+ ],
32
+ "sliftutils/*": [
33
+ "./node_modules/sliftutils/*.d.ts"
27
34
  ]
28
35
  },
29
36
  "experimentalDecorators": true,