sliftutils 1.7.8 → 1.7.10

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.
Files changed (43) hide show
  1. package/bin/storageserver.js +4 -0
  2. package/bundler/bundleEntry.ts +14 -10
  3. package/bundler/bundler.ts +6 -1
  4. package/{teststorage → examplestorage}/browser.tsx +18 -22
  5. package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
  6. package/index.d.ts +528 -78
  7. package/misc/https/dns.d.ts +7 -3
  8. package/misc/https/dns.ts +4 -9
  9. package/misc/https/hostServer.d.ts +6 -3
  10. package/misc/https/hostServer.ts +6 -6
  11. package/package.json +2 -1
  12. package/spec.txt +77 -42
  13. package/storage/ArchivesDisk.d.ts +65 -0
  14. package/storage/ArchivesDisk.ts +365 -0
  15. package/storage/IArchives.d.ts +95 -1
  16. package/storage/IArchives.ts +146 -1
  17. package/storage/backblaze.d.ts +14 -2
  18. package/storage/backblaze.ts +18 -2
  19. package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
  20. package/storage/remoteStorage/ArchivesRemote.ts +63 -63
  21. package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
  22. package/storage/remoteStorage/ArchivesUrl.ts +74 -0
  23. package/storage/remoteStorage/accessPage.tsx +111 -28
  24. package/storage/remoteStorage/blobStore.d.ts +79 -25
  25. package/storage/remoteStorage/blobStore.ts +441 -287
  26. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  27. package/storage/remoteStorage/cliArgs.ts +9 -0
  28. package/storage/remoteStorage/createArchives.d.ts +64 -0
  29. package/storage/remoteStorage/createArchives.ts +395 -0
  30. package/storage/remoteStorage/grantAccess.js +4 -0
  31. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  32. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  33. package/storage/remoteStorage/remoteConfig.d.ts +21 -0
  34. package/storage/remoteStorage/remoteConfig.ts +94 -0
  35. package/storage/remoteStorage/storageController.d.ts +19 -27
  36. package/storage/remoteStorage/storageController.ts +205 -136
  37. package/storage/remoteStorage/storageServer.d.ts +11 -0
  38. package/storage/remoteStorage/storageServer.ts +80 -93
  39. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  40. package/storage/remoteStorage/storageServerCli.ts +41 -0
  41. package/storage/remoteStorage/storageServerState.d.ts +37 -0
  42. package/storage/remoteStorage/storageServerState.ts +358 -0
  43. package/testsite/server.ts +1 -1
@@ -1,132 +1,119 @@
1
- process.env.NODE_ENV = "production";
2
1
  import os from "os";
3
2
  import path from "path";
3
+ import fsp from "fs/promises";
4
4
  import { SocketFunction } from "socket-function/SocketFunction";
5
5
  import { getExternalIP } from "socket-function/src/networking";
6
6
  import { RequireController } from "socket-function/require/RequireController";
7
7
  import { hostServer } from "../../misc/https/hostServer";
8
- import { getFileStorageNested2 } from "../FileFolderAPI";
9
- import { TransactionStorage } from "../TransactionStorage";
10
- import { JSONStorage } from "../JSONStorage";
11
- import { BlobStore } from "./blobStore";
12
- import {
13
- RemoteStorageController, setStorageServerState,
14
- AccessRequest, TrustRecord, BucketConfig,
15
- } from "./storageController";
16
- import { authenticateStorage } from "./ArchivesRemote";
8
+ import { getSecret } from "../../misc/getSecret";
9
+ import { RemoteStorageController } from "./storageController";
10
+ import { setStorageServerConfig, setWritesRejectedReason } from "./storageServerState";
11
+ import { parseStorageUrl } from "./ArchivesRemote";
17
12
  // Import browser code, so it is allowed to be required by the client
18
13
  import "./accessPage";
19
14
 
20
- // The remote storage server. Run modes:
21
- // Host the server:
22
- // typenode storage/remoteStorage/storageServer.ts --domain storage.example.com --port 4444
23
- // --folder /storage/data --cloudflareApiTokenPath ~/example.com.key
24
- // Admin commands (run on the storage machine itself, against the running server):
25
- // --listAccess <ip> lists pending access requests from that IP (with requestIds)
26
- // --grantAccess <requestId> trusts the machine from that request for the requested account
15
+ const DEFAULT_LOW_SPACE_THRESHOLD_BYTES = 25 * 1024 ** 3;
16
+ const DISK_SPACE_CHECK_INTERVAL_MS = 15 * 60 * 1000;
17
+ // Below this fraction of the warn threshold, we start rejecting writes so the server itself doesn't
18
+ // tip the machine into instability. Reads/deletes still work so users can free space.
19
+ const HARD_REJECT_FRACTION = 0.1;
27
20
 
28
- process.on("unhandledRejection", (error) => {
29
- console.error("Unhandled promise rejection:", error);
30
- });
31
- process.on("uncaughtException", (error) => {
32
- console.error("Uncaught exception:", error);
33
- });
21
+ // The remote storage server, as a library function: consumers call hostStorageServer() from their
22
+ // own process to start hosting (or use the storageserver bin, see storageServerCli.ts). The
23
+ // grantAccess.js bootstrap (next to this file) is what the access page's shown SSH command points at.
34
24
 
35
- // The absolute command that runs this script (with the given args) on this machine, usable from
36
- // any working directory (ex: over ssh)
37
- function getServerCommand(args: string): string {
38
- return `${process.execPath} ${require.resolve("typenode/bootstrap.js")} ${__filename} ${args}`;
39
- }
25
+ export type HostStorageServerConfig = {
26
+ // Full URL of this storage server, e.g. "https://storage.example.com:4444". The domain and
27
+ // port are extracted from it (bucket routing URLs clients use look like
28
+ // https://storage.example.com:4444/file/<account>/<bucketName>/storage/storagerouting.json).
29
+ url: string;
30
+ folder: string;
31
+ // Set hostServer.ts:HostServerConfig:cloudflareApiToken. Defaults to getSecret("cloudflare.json")
32
+ // (~/cloudflare.json, { key: string }).
33
+ cloudflareApiToken?: { key: string } | { path: string };
34
+ // When free space on the folder's drive drops below this many bytes, the server console.errors
35
+ // every 15 minutes. Below 10% of it, the server also rejects write operations (creating files,
36
+ // large uploads, new buckets) — reads, findInfo, and deletes still work so the user can free
37
+ // space. Default 25 GiB.
38
+ lowSpaceThresholdBytes?: number;
39
+ };
40
40
 
41
- function getArg(name: string): string | undefined {
42
- let index = process.argv.indexOf(`--${name}`);
43
- if (index < 0) return undefined;
44
- let value = process.argv[index + 1];
45
- if (!value || value.startsWith("--")) {
46
- throw new Error(`Missing value for --${name}`);
47
- }
48
- return value;
41
+ function formatBytes(bytes: number): string {
42
+ return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
49
43
  }
50
44
 
51
- async function runAdminCommand(config: { domain: string; port: number; listAccess?: string; grantAccess?: string }) {
52
- let nodeId = SocketFunction.connect({ address: config.domain, port: config.port });
53
- let controller = RemoteStorageController.nodes[nodeId];
54
- await authenticateStorage({ address: config.domain, port: config.port, nodeId });
55
- if (config.listAccess) {
56
- let requests = await controller.adminListRequests(config.listAccess);
57
- if (!requests.length) {
58
- console.log(`No access requests from ${config.listAccess}`);
59
- return;
60
- }
61
- console.log(`Access requests from ${config.listAccess}:`);
62
- for (let request of requests) {
63
- console.log(` --grantAccess ${request.requestId} (account ${request.account}, machine ${request.machineId}, requested ${new Date(request.time).toISOString()})`);
64
- }
65
- console.log(`Grant one with: ${getServerCommand(`--domain ${config.domain} --port ${config.port} --grantAccess <requestId>`)}`);
45
+ async function checkDiskSpace(config: { folder: string; threshold: number }): Promise<void> {
46
+ let { folder, threshold } = config;
47
+ let stats = await fsp.statfs(folder);
48
+ let free = Number(stats.bavail) * Number(stats.bsize);
49
+ let hardLimit = threshold * HARD_REJECT_FRACTION;
50
+ if (free >= threshold) {
51
+ setWritesRejectedReason(undefined);
66
52
  return;
67
53
  }
68
- if (config.grantAccess) {
69
- let record = await controller.adminGrantAccess(config.grantAccess);
70
- console.log(`Granted machine ${record.machineId} access to account ${JSON.stringify(record.account)}`);
54
+ let under = threshold - free;
55
+ let rejecting = free < hardLimit;
56
+ console.error(
57
+ `Storage folder ${folder} is low on disk: ${formatBytes(free)} free`
58
+ + ` (warn threshold ${formatBytes(threshold)}, ${formatBytes(under)} under;`
59
+ + ` hard-reject threshold ${formatBytes(hardLimit)}${rejecting ? ", REACHED — write ops now rejected" : ""}).`
60
+ );
61
+ if (rejecting) {
62
+ setWritesRejectedReason(
63
+ `Storage server is out of disk space: only ${formatBytes(free)} free on ${folder}`
64
+ + ` (hard-reject threshold ${formatBytes(hardLimit)}, warn threshold ${formatBytes(threshold)}).`
65
+ + ` Write operations (create/append/new bucket) are rejected; reads, findInfo, and deletes still work — please free space.`
66
+ );
67
+ } else {
68
+ setWritesRejectedReason(undefined);
71
69
  }
72
70
  }
73
71
 
74
- async function main() {
75
- let domain = getArg("domain");
76
- if (!domain) throw new Error(`--domain is required (ex: --domain storage.example.com)`);
77
- let port = +(getArg("port") || 443);
78
-
79
- let listAccess = getArg("listAccess");
80
- let grantAccess = getArg("grantAccess");
81
- if (listAccess || grantAccess) {
82
- await runAdminCommand({ domain, port, listAccess, grantAccess });
83
- process.exit(0);
84
- }
85
-
86
- let folder = getArg("folder");
87
- if (!folder) throw new Error(`--folder is required (where the storage server keeps its data)`);
88
- let cloudflareApiTokenPath = getArg("cloudflareApiTokenPath");
89
- if (!cloudflareApiTokenPath) throw new Error(`--cloudflareApiTokenPath is required (path to a Cloudflare API token file, for HTTPS certs)`);
90
-
91
- let root = await getFileStorageNested2(path.resolve(folder));
92
- let system = await root.folder.getStorage("system");
93
- let trust = new JSONStorage<TrustRecord>(new TransactionStorage(await system.folder.getStorage("trust"), "storageTrust"));
94
- let requests = new JSONStorage<AccessRequest[]>(new TransactionStorage(await system.folder.getStorage("requests"), "storageRequests"));
95
- let buckets = new JSONStorage<BucketConfig>(new TransactionStorage(await system.folder.getStorage("buckets"), "storageBuckets"));
72
+ // Full path to the grantAccess CLI bootstrap that lives next to this file. The SSH command shown on
73
+ // the access page invokes it via `node <path> ...` (found through __dirname so consumers don't have
74
+ // to know where our source lives).
75
+ function getGrantAccessCliPath(): string {
76
+ return path.join(__dirname, "grantAccess.js");
77
+ }
96
78
 
97
- setStorageServerState({
79
+ export async function hostStorageServer(config: HostStorageServerConfig): Promise<void> {
80
+ let { url, folder } = config;
81
+ let { address: domain, port } = parseStorageUrl(url);
82
+ let lowSpaceThreshold = config.lowSpaceThresholdBytes ?? DEFAULT_LOW_SPACE_THRESHOLD_BYTES;
83
+ setStorageServerConfig({
98
84
  domain,
99
85
  port,
100
86
  rootDomain: domain.split(".").slice(-2).join("."),
101
87
  sshTarget: `${os.userInfo().username}@${await getExternalIP()}`,
102
- serverCommand: getServerCommand(`--domain ${domain} --port ${port}`),
103
- blobStore: new BlobStore(path.resolve(folder)),
104
- trust,
105
- requests,
106
- buckets,
88
+ serverCommand: `node ${getGrantAccessCliPath()} --url ${url}`,
89
+ folder: path.resolve(folder),
107
90
  });
108
91
 
109
92
  RequireController.allowAllNodeModules();
110
93
  SocketFunction.expose(RequireController);
111
94
  SocketFunction.expose(RemoteStorageController);
112
- // No static roots, so the access page HTML is served at every path (the path is the account
113
- // name, see accessPage.tsx)
95
+ // Every HTTP path goes through httpEntry: /file/<account>/<bucketName>/... serves public
96
+ // bucket files, everything else serves the access page (the path is the account name, see
97
+ // accessPage.tsx).
114
98
  // A full URL, so the page resolves modules from the origin root even when served at
115
- // /accountName (a relative require would resolve inside the account path)
116
- SocketFunction.setDefaultHTTPCall(RequireController, "requireHTML", {
99
+ // /accountName (a relative require would resolve inside the account path).
100
+ SocketFunction.setDefaultHTTPCall(RemoteStorageController, "httpEntry", {
117
101
  requireCalls: [`https://${domain}:${port}/./storage/remoteStorage/accessPage.tsx`],
118
102
  });
119
103
 
104
+ // Initial check so a server starting under-limit immediately rejects writes; then keep checking
105
+ // every 15 minutes so recovery (freed disk space) is picked up automatically.
106
+ await checkDiskSpace({ folder, threshold: lowSpaceThreshold });
107
+ let interval = setInterval(() => {
108
+ void checkDiskSpace({ folder, threshold: lowSpaceThreshold })
109
+ .catch(e => console.error(`Disk space check failed for ${folder}:`, e));
110
+ }, DISK_SPACE_CHECK_INTERVAL_MS);
111
+ (interval as { unref?: () => void }).unref?.();
112
+
120
113
  await hostServer({
121
114
  domain,
122
115
  port,
123
- cloudflareApiTokenPath,
116
+ cloudflareApiToken: config.cloudflareApiToken || { key: await getSecret("cloudflare.json.key") },
124
117
  setDNSRecord: true,
125
118
  });
126
- console.log(`Storage server running at https://${domain}:${port}`);
127
119
  }
128
-
129
- main().catch(e => {
130
- console.error(e);
131
- process.exit(1);
132
- });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ process.env.NODE_ENV = "production";
2
+ import { hostStorageServer } from "./storageServer";
3
+ import { getArg } from "./cliArgs";
4
+
5
+ // Hosts a storage server from the command line (via the storageserver bin, see package.json).
6
+
7
+ process.on("unhandledRejection", (error) => {
8
+ console.error("Unhandled promise rejection:", error);
9
+ });
10
+ process.on("uncaughtException", (error) => {
11
+ console.error("Uncaught exception:", error);
12
+ });
13
+
14
+ async function main() {
15
+ let url = getArg("url");
16
+ if (!url) throw new Error(`--url is required (ex: --url https://storage.example.com:4444)`);
17
+ let folder = getArg("folder");
18
+ if (!folder) throw new Error(`--folder is required (the folder all data is stored in)`);
19
+ // Optional: hostStorageServer falls back to ~/cloudflare.json
20
+ let cloudflareApiToken = getArg("cloudflareApiToken");
21
+ let lowSpaceThresholdBytes: number | undefined;
22
+ let lowSpaceThreshold = getArg("lowSpaceThreshold");
23
+ if (lowSpaceThreshold) {
24
+ lowSpaceThresholdBytes = +lowSpaceThreshold;
25
+ if (isNaN(lowSpaceThresholdBytes)) {
26
+ throw new Error(`--lowSpaceThreshold must be a number of bytes, was ${JSON.stringify(lowSpaceThreshold)}`);
27
+ }
28
+ }
29
+
30
+ await hostStorageServer({
31
+ url,
32
+ folder,
33
+ cloudflareApiToken: cloudflareApiToken && { path: cloudflareApiToken } || undefined,
34
+ lowSpaceThresholdBytes,
35
+ });
36
+ }
37
+
38
+ main().catch(e => {
39
+ console.error(e);
40
+ process.exit(1);
41
+ });
@@ -0,0 +1,37 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { IBucketStore } from "./blobStore";
4
+ import { RemoteConfig, HostedConfig, IArchives } from "../IArchives";
5
+ import type { IStorage } from "../IStorage";
6
+ import type { AccessRequest, TrustRecord } from "./storageController";
7
+ export type StorageServerConfig = {
8
+ domain: string;
9
+ port: number;
10
+ rootDomain: string;
11
+ sshTarget: string;
12
+ serverCommand: string;
13
+ folder: string;
14
+ };
15
+ export declare function setStorageServerConfig(value: StorageServerConfig): void;
16
+ export declare function getStorageServerConfig(): StorageServerConfig;
17
+ export declare function getStorageServerConfigOptional(): StorageServerConfig | undefined;
18
+ export declare function setWritesRejectedReason(reason: string | undefined): void;
19
+ export declare function getWritesRejectedReason(): string | undefined;
20
+ export declare function assertWritesAllowed(): void;
21
+ export declare function getTrust(): Promise<IStorage<TrustRecord>>;
22
+ export declare function getRequests(): Promise<IStorage<AccessRequest[]>>;
23
+ export type LoadedBucket = {
24
+ account: string;
25
+ bucketName: string;
26
+ routing: RemoteConfig;
27
+ routingJSON: string;
28
+ self: HostedConfig | undefined;
29
+ store: IBucketStore;
30
+ };
31
+ export declare function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined>;
32
+ export declare function assertMutable(bucket: LoadedBucket, filePath: string): Promise<void>;
33
+ export declare function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: {
34
+ lastModified?: number;
35
+ }): Promise<void>;
36
+ export declare function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void>;
37
+ export declare function getLocalArchives(account: string, bucketName: string): IArchives;
@@ -0,0 +1,358 @@
1
+ import path from "path";
2
+ import { getFileStorageNested2 } from "../FileFolderAPI";
3
+ import { TransactionStorage } from "../TransactionStorage";
4
+ import { JSONStorage } from "../JSONStorage";
5
+ import { ArchivesDisk } from "../ArchivesDisk";
6
+ import { BlobStore, IBucketStore } from "./blobStore";
7
+ import {
8
+ RemoteConfig, HostedConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
9
+ ArchivesSyncStatus, DEFAULT_BASE_SYNC_OPTIONS, DEFAULT_SYNC_OPTIONS,
10
+ } from "../IArchives";
11
+ import { ROUTING_FILE, parseRoutingData, parseHostedUrl, buildFileUrl } from "./remoteConfig";
12
+ import { createApiArchives } from "./createArchives";
13
+ import type { IStorage } from "../IStorage";
14
+ import type { AccessRequest, TrustRecord } from "./storageController";
15
+
16
+ // The storage server's global server-side state. hostStorageServer sets the config once at
17
+ // startup; everything else (system storages, buckets) is a global cache created lazily on first
18
+ // use, throwing if the config was never set.
19
+ //
20
+ // Buckets have no separate registry: each bucket's configuration (a RemoteConfig) lives inside the
21
+ // bucket itself, at ROUTING_FILE. A bucket exists iff that file exists. Writing it creates the
22
+ // bucket (building its store, which may immediately start synchronization scans); overwriting it —
23
+ // directly or via a file pulled in from a synchronization source — rebuilds the store with the new
24
+ // sources (cancelling the old store's scans).
25
+
26
+ export type StorageServerConfig = {
27
+ domain: string;
28
+ port: number;
29
+ rootDomain: string;
30
+ // user@externalIp of the storage machine, for generated ssh commands
31
+ sshTarget: string;
32
+ // Absolute command that runs the grantAccess CLI on the storage machine (admin args appended)
33
+ serverCommand: string;
34
+ // The server's root storage folder (absolute)
35
+ folder: string;
36
+ };
37
+
38
+ let config: StorageServerConfig | undefined;
39
+ export function setStorageServerConfig(value: StorageServerConfig): void {
40
+ config = value;
41
+ }
42
+ export function getStorageServerConfig(): StorageServerConfig {
43
+ if (!config) {
44
+ throw new Error(`Storage server is not initialized (this API only works on the storage server)`);
45
+ }
46
+ return config;
47
+ }
48
+ // For self-detection: code that CAN run outside the storage server (createArchives) uses this to
49
+ // check "is this URL our own process?" without throwing.
50
+ export function getStorageServerConfigOptional(): StorageServerConfig | undefined {
51
+ return config;
52
+ }
53
+
54
+ // When set, all write-path operations (creating files, appending large uploads, creating buckets)
55
+ // throw this message. Reads, findInfo, and deletes still work — so clients can free space. Managed
56
+ // by hostStorageServer's disk-space monitor.
57
+ let writesRejectedReason: string | undefined;
58
+ export function setWritesRejectedReason(reason: string | undefined): void {
59
+ writesRejectedReason = reason;
60
+ }
61
+ export function getWritesRejectedReason(): string | undefined {
62
+ return writesRejectedReason;
63
+ }
64
+ export function assertWritesAllowed(): void {
65
+ if (writesRejectedReason) throw new Error(writesRejectedReason);
66
+ }
67
+
68
+ // The system storages (trust, requests) are the same kind of thing — a JSON key/value store under
69
+ // <folder>/system2/<name> — so they share one cache, keyed by name. ("system2" because the layout
70
+ // changed when bucket configs moved into the buckets themselves; old "system"/"buckets" folders
71
+ // are simply ignored.)
72
+ const systemStorages = new Map<string, Promise<IStorage<unknown>>>();
73
+ function getSystemStorage<T>(name: string): Promise<IStorage<T>> {
74
+ let storage = systemStorages.get(name);
75
+ if (!storage) {
76
+ storage = (async () => {
77
+ let root = await getFileStorageNested2(getStorageServerConfig().folder);
78
+ let system = await root.folder.getStorage("system2");
79
+ let transactionName = "storage" + name[0].toUpperCase() + name.slice(1);
80
+ return new JSONStorage<unknown>(new TransactionStorage(await system.folder.getStorage(name), transactionName));
81
+ })();
82
+ systemStorages.set(name, storage);
83
+ }
84
+ return storage as Promise<IStorage<T>>;
85
+ }
86
+ export function getTrust(): Promise<IStorage<TrustRecord>> {
87
+ return getSystemStorage<TrustRecord>("trust");
88
+ }
89
+ export function getRequests(): Promise<IStorage<AccessRequest[]>> {
90
+ return getSystemStorage<AccessRequest[]>("requests");
91
+ }
92
+
93
+ // ── buckets ──
94
+
95
+ export type LoadedBucket = {
96
+ account: string;
97
+ bucketName: string;
98
+ // The bucket's parsed routing config (the contents of ROUTING_FILE)
99
+ routing: RemoteConfig;
100
+ // JSON of routing, for change detection
101
+ routingJSON: string;
102
+ // Our own entry in routing.sources (this bucket on this server), holding the bucket options
103
+ // (public/fast/rawDisk/immutable/...). undefined when the config doesn't mention us.
104
+ self: HostedConfig | undefined;
105
+ store: IBucketStore;
106
+ };
107
+
108
+ const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
109
+
110
+ function getBucketFolder(account: string, bucketName: string): string {
111
+ return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
112
+ }
113
+
114
+ function findSelfEntry(routing: RemoteConfig, account: string, bucketName: string): HostedConfig | undefined {
115
+ let { domain, port } = getStorageServerConfig();
116
+ for (let source of routing.sources) {
117
+ if (typeof source === "string" || source.type !== "remote") continue;
118
+ let parsed = parseHostedUrl(source.url);
119
+ if (parsed.address === domain && parsed.port === port && parsed.account === account && parsed.bucketName === bucketName) {
120
+ return source;
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ function buildBucket(account: string, bucketName: string, routing: RemoteConfig): LoadedBucket {
127
+ let folder = getBucketFolder(account, bucketName);
128
+ let self = findSelfEntry(routing, account, bucketName);
129
+ let store: IBucketStore;
130
+ if (self?.rawDisk) {
131
+ store = new ArchivesDisk(folder);
132
+ } else {
133
+ // Our own disk is the base source; the other routing entries are synchronization sources
134
+ let sources: ArchivesSource[] = [{
135
+ source: new ArchivesDisk(folder),
136
+ options: self?.syncOptions || DEFAULT_BASE_SYNC_OPTIONS,
137
+ }];
138
+ for (let source of routing.sources) {
139
+ if (typeof source === "string" || source === self) continue;
140
+ sources.push({ source: createApiArchives(source), options: source.syncOptions || DEFAULT_SYNC_OPTIONS });
141
+ }
142
+ store = new BlobStore(folder, sources, {
143
+ onIndexChanged: key => {
144
+ // Fires for our own routing writes AND routing files pulled in via synchronization
145
+ // (only ever newer ones, see IArchives.set) — either way, apply the new config
146
+ if (key !== ROUTING_FILE) return;
147
+ void scheduleRoutingReload(account, bucketName);
148
+ },
149
+ });
150
+ }
151
+ return { account, bucketName, routing, routingJSON: JSON.stringify(routing), self, store };
152
+ }
153
+
154
+ async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
155
+ let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
156
+ if (!data) return undefined;
157
+ return buildBucket(account, bucketName, parseRoutingData(data));
158
+ }
159
+
160
+ export function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
161
+ let key = `${account}/${bucketName}`;
162
+ let loaded = buckets.get(key);
163
+ if (!loaded) {
164
+ loaded = loadBucket(account, bucketName);
165
+ buckets.set(key, loaded);
166
+ // Missing buckets are not cached — they can be created at any time (guarding against a
167
+ // concurrent create having already replaced our entry)
168
+ void loaded.then(bucket => {
169
+ if (!bucket && buckets.get(key) === loaded) {
170
+ buckets.delete(key);
171
+ }
172
+ }, () => {
173
+ if (buckets.get(key) === loaded) {
174
+ buckets.delete(key);
175
+ }
176
+ });
177
+ }
178
+ return loaded;
179
+ }
180
+
181
+ // Routing reloads are serialized per bucket, so concurrent writes/syncs can't rebuild the same
182
+ // store twice in parallel
183
+ const routingReloads = new Map<string, Promise<void>>();
184
+ function scheduleRoutingReload(account: string, bucketName: string): Promise<void> {
185
+ let key = `${account}/${bucketName}`;
186
+ let next = (routingReloads.get(key) || Promise.resolve())
187
+ .then(() => checkRoutingChanged(account, bucketName))
188
+ .catch(e => console.error(`Reloading routing config for bucket ${key} failed:`, e));
189
+ routingReloads.set(key, next);
190
+ return next;
191
+ }
192
+
193
+ async function checkRoutingChanged(account: string, bucketName: string): Promise<void> {
194
+ let key = `${account}/${bucketName}`;
195
+ let loaded = await buckets.get(key);
196
+ if (!loaded) return;
197
+ // get() cache-reads the file onto our disk when a remote source holds it, so the bucket still
198
+ // exists after a restart
199
+ let data = await loaded.store.get(ROUTING_FILE);
200
+ if (!data) return;
201
+ let routing = parseRoutingData(data);
202
+ if (JSON.stringify(routing) === loaded.routingJSON) return;
203
+ console.log(`Routing config changed for bucket ${key}, rebuilding its store`);
204
+ if (loaded.store instanceof BlobStore) {
205
+ await loaded.store.dispose();
206
+ }
207
+ buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing)));
208
+ }
209
+
210
+ function getWriteConfig(bucket: LoadedBucket): { fast?: boolean; writeDelay?: number } {
211
+ return { fast: bucket.self?.fast, writeDelay: bucket.self?.writeDelay };
212
+ }
213
+
214
+ export async function assertMutable(bucket: LoadedBucket, filePath: string): Promise<void> {
215
+ if (!bucket.self?.immutable) return;
216
+ if (await bucket.store.getInfo(filePath)) {
217
+ throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} is immutable and ${JSON.stringify(filePath)} already exists, so it cannot be written to`);
218
+ }
219
+ }
220
+
221
+ export async function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
222
+ assertWritesAllowed();
223
+ let loaded = await getLoadedBucket(account, bucketName);
224
+ if (filePath === ROUTING_FILE) {
225
+ // Validates before storing anything, so a bad config can't brick the bucket
226
+ parseRoutingData(data);
227
+ if (!loaded) {
228
+ let key = `${account}/${bucketName}`;
229
+ await new ArchivesDisk(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
230
+ buckets.set(key, Promise.resolve(buildBucket(account, bucketName, parseRoutingData(data))));
231
+ console.log(`Created bucket ${key}`);
232
+ return;
233
+ }
234
+ // Routing writes bypass fast mode (the config must apply immediately and survive a crash)
235
+ // and ignore immutable (the routing file must always stay updatable). An older lastModified
236
+ // no-ops inside set, in which case the reload below sees no change.
237
+ await loaded.store.set(ROUTING_FILE, data, { lastModified: config?.lastModified });
238
+ await scheduleRoutingReload(account, bucketName);
239
+ return;
240
+ }
241
+ if (!loaded) {
242
+ throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
243
+ }
244
+ await assertMutable(loaded, filePath);
245
+ await loaded.store.set(filePath, data, { ...getWriteConfig(loaded), lastModified: config?.lastModified });
246
+ }
247
+
248
+ export async function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void> {
249
+ if (filePath === ROUTING_FILE) {
250
+ throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
251
+ }
252
+ let loaded = await getLoadedBucket(account, bucketName);
253
+ if (!loaded) return;
254
+ await loaded.store.del(filePath, getWriteConfig(loaded));
255
+ }
256
+
257
+ // ── local IArchives access ──
258
+
259
+ const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
260
+
261
+ // The in-process IArchives for a bucket hosted by THIS server — used when a RemoteConfig source
262
+ // URL points at ourselves, so we don't talk to ourselves over HTTPS. One singleton per bucket.
263
+ class ArchivesLocalBucket implements IArchives {
264
+ constructor(private account: string, private bucketName: string) { }
265
+
266
+ private async getBucket(): Promise<LoadedBucket | undefined> {
267
+ return await getLoadedBucket(this.account, this.bucketName);
268
+ }
269
+
270
+ public getDebugName() {
271
+ return `localBucket/${this.account}/${this.bucketName}`;
272
+ }
273
+ public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
274
+ let result = await this.get2(fileName, config);
275
+ return result && result.data || undefined;
276
+ }
277
+ public async get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
278
+ let bucket = await this.getBucket();
279
+ if (!bucket) return undefined;
280
+ return await bucket.store.get2(fileName, config);
281
+ }
282
+ public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
283
+ await writeBucketFile(this.account, this.bucketName, fileName, data, config);
284
+ }
285
+ public async del(fileName: string): Promise<void> {
286
+ await deleteBucketFile(this.account, this.bucketName, fileName);
287
+ }
288
+ public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
289
+ assertWritesAllowed();
290
+ let bucket = await this.getBucket();
291
+ if (!bucket) {
292
+ throw new Error(`Bucket ${this.account}/${this.bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
293
+ }
294
+ await assertMutable(bucket, config.path);
295
+ let id = await bucket.store.startLargeUpload();
296
+ try {
297
+ while (true) {
298
+ let data = await config.getNextData();
299
+ if (!data) break;
300
+ for (let offset = 0; offset < data.length; offset += LARGE_FILE_PART_SIZE) {
301
+ await bucket.store.appendLargeUpload(id, data.subarray(offset, offset + LARGE_FILE_PART_SIZE));
302
+ }
303
+ }
304
+ await bucket.store.finishLargeUpload(id, config.path);
305
+ } catch (e) {
306
+ await bucket.store.cancelLargeUpload(id);
307
+ throw e;
308
+ }
309
+ }
310
+ public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
311
+ let bucket = await this.getBucket();
312
+ if (!bucket) return undefined;
313
+ return await bucket.store.getInfo(fileName);
314
+ }
315
+ public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
316
+ let bucket = await this.getBucket();
317
+ if (!bucket) return [];
318
+ return await bucket.store.findInfo(prefix, config);
319
+ }
320
+ public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
321
+ return (await this.findInfo(prefix, config)).map(x => x.path);
322
+ }
323
+ public async getURL(filePath: string): Promise<string> {
324
+ let { domain, port } = getStorageServerConfig();
325
+ return buildFileUrl(`https://${domain}:${port}/file/${encodeURIComponent(this.account)}/${encodeURIComponent(this.bucketName)}`, filePath);
326
+ }
327
+ public async getConfig(): Promise<ArchivesConfig> {
328
+ let bucket = await this.getBucket();
329
+ // Missing buckets say true, matching what they become once created (the default store type)
330
+ return { supportsChangesAfter: !bucket || !!bucket.store.getChangesAfter };
331
+ }
332
+ public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
333
+ let bucket = await this.getBucket();
334
+ if (!bucket) return [];
335
+ if (!bucket.store.getChangesAfter) {
336
+ throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getChangesAfter (rawDisk buckets have no index)`);
337
+ }
338
+ return await bucket.store.getChangesAfter(time);
339
+ }
340
+ public async getSyncStatus(): Promise<ArchivesSyncStatus> {
341
+ let bucket = await this.getBucket();
342
+ if (!bucket) return { allScansComplete: true, indexSize: 0, sources: [] };
343
+ if (!bucket.store.getSyncStatus) {
344
+ throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
345
+ }
346
+ return await bucket.store.getSyncStatus();
347
+ }
348
+ }
349
+
350
+ const localArchives = new Map<string, IArchives>();
351
+ export function getLocalArchives(account: string, bucketName: string): IArchives {
352
+ let key = `${account}/${bucketName}`;
353
+ let existing = localArchives.get(key);
354
+ if (existing) return existing;
355
+ let archives = new ArchivesLocalBucket(account, bucketName);
356
+ localArchives.set(key, archives);
357
+ return archives;
358
+ }
@@ -30,7 +30,7 @@ async function main() {
30
30
  await hostServer({
31
31
  domain: DOMAIN,
32
32
  port: PORT,
33
- cloudflareApiTokenPath: os.homedir() + "/vidgridweb.com.key",
33
+ cloudflareApiToken: { path: os.homedir() + "/vidgridweb.com.key" },
34
34
  setDNSRecord: true,
35
35
  });
36
36
  }