sliftutils 1.7.9 → 1.7.11
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/bin/storageserver.js +4 -0
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +4 -6
- package/index.d.ts +770 -84
- package/misc/dist/environment.ts.cache +3 -3
- package/misc/dist/getSecret.ts.cache +139 -0
- package/misc/dist/strings.ts.cache +13 -0
- package/misc/dist/zip.ts.cache +9 -0
- package/misc/getSecret.ts +38 -16
- package/misc/https/cloudflareHelpers.d.ts +19 -0
- package/misc/https/cloudflareHelpers.ts +69 -0
- package/misc/https/dist/certs.ts.cache +115 -179
- package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
- package/misc/https/dist/dns.ts.cache +112 -100
- package/misc/https/dist/hostServer.ts.cache +130 -0
- package/misc/https/dist/httpsCerts.ts.cache +24 -5
- package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
- package/misc/https/dns.d.ts +41 -8
- package/misc/https/dns.ts +104 -106
- package/misc/https/hostServer.d.ts +0 -3
- package/misc/https/hostServer.ts +12 -8
- package/package.json +3 -2
- package/render-utils/dist/observer.tsx.cache +3 -2
- package/spec.txt +0 -55
- package/storage/ArchivesDisk.d.ts +67 -0
- package/storage/ArchivesDisk.ts +369 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
- package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
- package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
- package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
- package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
- package/storage/IArchives.d.ts +127 -1
- package/storage/IArchives.ts +201 -1
- package/storage/backblaze.d.ts +29 -2
- package/storage/backblaze.ts +214 -78
- package/storage/dist/ArchivesDisk.ts.cache +373 -0
- package/storage/dist/FileFolderAPI.tsx.cache +508 -65
- package/storage/dist/IArchives.ts.cache +44 -0
- package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
- package/storage/dist/JSONStorage.ts.cache +6 -2
- package/storage/dist/PendingManager.tsx.cache +1 -1
- package/storage/dist/TransactionStorage.ts.cache +231 -92
- package/storage/dist/backblaze.ts.cache +797 -0
- package/storage/dist/fileSystemPointer.ts.cache +36 -3
- package/storage/dist/remoteFileStorage.ts.cache +495 -0
- package/storage/remoteStorage/ArchivesRemote.d.ts +34 -18
- package/storage/remoteStorage/ArchivesRemote.ts +85 -65
- package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
- package/storage/remoteStorage/ArchivesUrl.ts +86 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +128 -25
- package/storage/remoteStorage/blobStore.ts +757 -290
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +85 -0
- package/storage/remoteStorage/createArchives.ts +612 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
- package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
- package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
- package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
- package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
- package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
- package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
- package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
- package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
- package/storage/remoteStorage/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +29 -0
- package/storage/remoteStorage/remoteConfig.ts +194 -0
- package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
- package/storage/remoteStorage/sourceWrapper.ts +223 -0
- package/storage/remoteStorage/spec.md +31 -0
- package/storage/remoteStorage/storageController.d.ts +30 -27
- package/storage/remoteStorage/storageController.ts +297 -159
- package/storage/remoteStorage/storageServer.d.ts +6 -0
- package/storage/remoteStorage/storageServer.ts +88 -94
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +38 -0
- package/storage/remoteStorage/storageServerState.d.ts +43 -0
- package/storage/remoteStorage/storageServerState.ts +540 -0
- package/testsite/server.ts +0 -2
- package/yarn.lock +11 -6
|
@@ -0,0 +1,540 @@
|
|
|
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, FULL_VALID_WINDOW,
|
|
10
|
+
WRITE_PAST_WINDOW_GRACE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, FULL_ROUTE,
|
|
11
|
+
} from "../IArchives";
|
|
12
|
+
import { ROUTING_FILE, parseRoutingData, parseHostedUrl, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection } from "./remoteConfig";
|
|
13
|
+
import { createApiArchives } from "./createArchives";
|
|
14
|
+
import type { IStorage } from "../IStorage";
|
|
15
|
+
import type { AccessRequest, TrustRecord } from "./storageController";
|
|
16
|
+
import { getArg } from "./cliArgs";
|
|
17
|
+
|
|
18
|
+
// The storage server's global server-side state. hostStorageServer sets the config once at
|
|
19
|
+
// startup; everything else (system storages, buckets) is a global cache created lazily on first
|
|
20
|
+
// use, throwing if the config was never set.
|
|
21
|
+
//
|
|
22
|
+
// Buckets have no separate registry: each bucket's configuration (a RemoteConfig) lives inside the
|
|
23
|
+
// bucket itself, at ROUTING_FILE. A bucket exists iff that file exists. Writing it creates the
|
|
24
|
+
// bucket (building its store, which may immediately start synchronization scans); overwriting it —
|
|
25
|
+
// directly or via a file pulled in from a synchronization source — rebuilds the store with the new
|
|
26
|
+
// sources (cancelling the old store's scans).
|
|
27
|
+
|
|
28
|
+
export type StorageServerConfig = {
|
|
29
|
+
domain: string;
|
|
30
|
+
port: number;
|
|
31
|
+
rootDomain: string;
|
|
32
|
+
// user@externalIp of the storage machine, for generated ssh commands
|
|
33
|
+
sshTarget: string;
|
|
34
|
+
// Absolute command that runs the grantAccess CLI on the storage machine (admin args appended)
|
|
35
|
+
serverCommand: string;
|
|
36
|
+
// The server's root storage folder (absolute)
|
|
37
|
+
folder: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let config: StorageServerConfig | undefined;
|
|
41
|
+
export function setStorageServerConfig(value: StorageServerConfig): void {
|
|
42
|
+
config = value;
|
|
43
|
+
}
|
|
44
|
+
export function getStorageServerConfig(): StorageServerConfig {
|
|
45
|
+
if (!config) {
|
|
46
|
+
throw new Error(`Storage server is not initialized (this API only works on the storage server)`);
|
|
47
|
+
}
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
// For self-detection: code that CAN run outside the storage server (createArchives) uses this to
|
|
51
|
+
// check "is this URL our own process?" without throwing.
|
|
52
|
+
export function getStorageServerConfigOptional(): StorageServerConfig | undefined {
|
|
53
|
+
return config;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// When set, all write-path operations (creating files, appending large uploads, creating buckets)
|
|
57
|
+
// throw this message. Reads, findInfo, and deletes still work — so clients can free space. Managed
|
|
58
|
+
// by hostStorageServer's disk-space monitor.
|
|
59
|
+
let writesRejectedReason: string | undefined;
|
|
60
|
+
export function setWritesRejectedReason(reason: string | undefined): void {
|
|
61
|
+
writesRejectedReason = reason;
|
|
62
|
+
}
|
|
63
|
+
export function getWritesRejectedReason(): string | undefined {
|
|
64
|
+
return writesRejectedReason;
|
|
65
|
+
}
|
|
66
|
+
export function assertWritesAllowed(): void {
|
|
67
|
+
if (writesRejectedReason) throw new Error(writesRejectedReason);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The system storages (trust, requests) are the same kind of thing — a JSON key/value store under
|
|
71
|
+
// <folder>/system2/<name> — so they share one cache, keyed by name. ("system2" because the layout
|
|
72
|
+
// changed when bucket configs moved into the buckets themselves; old "system"/"buckets" folders
|
|
73
|
+
// are simply ignored.)
|
|
74
|
+
// setTrustedMachines is called by consumers BEFORE starting the server, so when the config isn't
|
|
75
|
+
// set yet we fall back to the same --folder arg the CLI starts the server with.
|
|
76
|
+
function getStorageFolder(): string {
|
|
77
|
+
let config = getStorageServerConfigOptional();
|
|
78
|
+
if (config) return config.folder;
|
|
79
|
+
let folder = getArg("folder");
|
|
80
|
+
if (!folder) {
|
|
81
|
+
throw new Error(`Storage server is not initialized and there is no --folder arg, so the storage folder is unknown`);
|
|
82
|
+
}
|
|
83
|
+
return path.resolve(folder);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const systemStorages = new Map<string, Promise<IStorage<unknown>>>();
|
|
87
|
+
function getSystemStorage<T>(name: string): Promise<IStorage<T>> {
|
|
88
|
+
let storage = systemStorages.get(name);
|
|
89
|
+
if (!storage) {
|
|
90
|
+
storage = (async () => {
|
|
91
|
+
let root = await getFileStorageNested2(getStorageFolder());
|
|
92
|
+
let system = await root.folder.getStorage("system2");
|
|
93
|
+
let transactionName = "storage" + name[0].toUpperCase() + name.slice(1);
|
|
94
|
+
return new JSONStorage<unknown>(new TransactionStorage(await system.folder.getStorage(name), transactionName));
|
|
95
|
+
})();
|
|
96
|
+
systemStorages.set(name, storage);
|
|
97
|
+
}
|
|
98
|
+
return storage as Promise<IStorage<T>>;
|
|
99
|
+
}
|
|
100
|
+
export function getTrust(): Promise<IStorage<TrustRecord>> {
|
|
101
|
+
return getSystemStorage<TrustRecord>("trust");
|
|
102
|
+
}
|
|
103
|
+
export function getRequests(): Promise<IStorage<AccessRequest[]>> {
|
|
104
|
+
return getSystemStorage<AccessRequest[]>("requests");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Makes machineIds the complete trust list for the account: machines not in the list lose access, machines already trusted keep their existing record, and missing ones are added. */
|
|
108
|
+
export async function setTrustedMachines(config: { account: string; machineIds: string[] }): Promise<void> {
|
|
109
|
+
let trust = await getTrust();
|
|
110
|
+
let prefix = `${config.account}|`;
|
|
111
|
+
let desired = new Set(config.machineIds);
|
|
112
|
+
for (let key of await trust.getKeys()) {
|
|
113
|
+
if (!key.startsWith(prefix)) continue;
|
|
114
|
+
let machineId = key.slice(prefix.length);
|
|
115
|
+
if (desired.has(machineId)) {
|
|
116
|
+
desired.delete(machineId);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
console.log(`Removing trust for machine ${machineId} on account ${config.account}`);
|
|
120
|
+
await trust.remove(key);
|
|
121
|
+
}
|
|
122
|
+
for (let machineId of desired) {
|
|
123
|
+
console.log(`Adding trust for machine ${machineId} on account ${config.account}`);
|
|
124
|
+
await trust.set(`${prefix}${machineId}`, { account: config.account, machineId, ip: "", time: Date.now() });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── buckets ──
|
|
129
|
+
|
|
130
|
+
export type LoadedBucket = {
|
|
131
|
+
account: string;
|
|
132
|
+
bucketName: string;
|
|
133
|
+
// The bucket's parsed routing config (the contents of ROUTING_FILE)
|
|
134
|
+
routing: RemoteConfig;
|
|
135
|
+
// JSON of routing, for change detection
|
|
136
|
+
routingJSON: string;
|
|
137
|
+
// ALL of our own entries in routing.sources (this bucket on this server). More than one when
|
|
138
|
+
// the bucket's options change over time - one entry per valid window (e.g. immutable dropped,
|
|
139
|
+
// or fast enabled, starting at a scheduled instant).
|
|
140
|
+
selfEntries: HostedConfig[];
|
|
141
|
+
// The entry valid when the store was built - the structural options (rawDisk, which entries
|
|
142
|
+
// are downstream). Per-write options (fast/immutable) are instead evaluated at each write's
|
|
143
|
+
// time, see getWriteConfig/assertMutable. undefined when the config doesn't mention us.
|
|
144
|
+
self: HostedConfig | undefined;
|
|
145
|
+
store: IBucketStore;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
|
|
149
|
+
|
|
150
|
+
// setTimeout cannot represent longer delays; more distant window boundaries re-check after this
|
|
151
|
+
const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
|
|
152
|
+
// Rebuild slightly after the boundary, so the newly-valid entry is unambiguously active
|
|
153
|
+
const REBUILD_BOUNDARY_BUFFER = 1000;
|
|
154
|
+
|
|
155
|
+
function getBucketFolder(account: string, bucketName: string): string {
|
|
156
|
+
return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findSelfIndexes(routing: RemoteConfig, account: string, bucketName: string): number[] {
|
|
160
|
+
let { domain, port } = getStorageServerConfig();
|
|
161
|
+
let indexes: number[] = [];
|
|
162
|
+
for (let i = 0; i < routing.sources.length; i++) {
|
|
163
|
+
let source = routing.sources[i];
|
|
164
|
+
if (typeof source === "string" || source.type !== "remote") continue;
|
|
165
|
+
let parsed = parseHostedUrl(source.url);
|
|
166
|
+
if (parsed.address === domain && parsed.port === port && parsed.account === account && parsed.bucketName === bucketName) {
|
|
167
|
+
indexes.push(i);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return indexes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The entry whose valid window contains the time (among the entries covering the key's route,
|
|
174
|
+
// when one is given - a server can hold different shards with different options). Half-open
|
|
175
|
+
// ([start, end)), so clean breaks resolve unambiguously. Falls back to the nearest window when
|
|
176
|
+
// none contains the time - gaps shouldn't exist, but a write must still resolve to SOME
|
|
177
|
+
// configuration.
|
|
178
|
+
function selectEntryAt(entries: HostedConfig[], time: number, route?: number): HostedConfig | undefined {
|
|
179
|
+
if (route !== undefined) {
|
|
180
|
+
let covering = entries.filter(x => routeContains(x.route, route));
|
|
181
|
+
if (covering.length) {
|
|
182
|
+
entries = covering;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let containing = entries.find(x => x.validWindow[0] <= time && time < x.validWindow[1]);
|
|
186
|
+
if (containing) return containing;
|
|
187
|
+
let best: HostedConfig | undefined;
|
|
188
|
+
let bestDistance = Infinity;
|
|
189
|
+
for (let entry of entries) {
|
|
190
|
+
let distance = Math.min(Math.abs(time - entry.validWindow[0]), Math.abs(time - entry.validWindow[1]));
|
|
191
|
+
if (distance < bestDistance) {
|
|
192
|
+
bestDistance = distance;
|
|
193
|
+
best = entry;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return best;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Bucket options can change over time (multiple self entries with different valid windows), and
|
|
200
|
+
// the structural ones (rawDisk, the downstream list) are baked into the store - so the store is
|
|
201
|
+
// rebuilt shortly after each of our window boundaries passes, making the newly-valid entry apply.
|
|
202
|
+
function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
203
|
+
let now = Date.now();
|
|
204
|
+
let boundaries = loaded.selfEntries.flatMap(x => x.validWindow).filter(t => t > now);
|
|
205
|
+
if (!boundaries.length) return;
|
|
206
|
+
let nextBoundary = Math.min(...boundaries);
|
|
207
|
+
let key = `${loaded.account}/${loaded.bucketName}`;
|
|
208
|
+
let timer = setTimeout(() => {
|
|
209
|
+
void (async () => {
|
|
210
|
+
// A routing change may have already replaced this store; only IT gets a boundary timer
|
|
211
|
+
let current = await buckets.get(key);
|
|
212
|
+
if (current !== loaded) return;
|
|
213
|
+
if (Date.now() < nextBoundary) {
|
|
214
|
+
scheduleWindowBoundaryRebuild(loaded);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await scheduleRoutingReload(loaded.account, loaded.bucketName, { force: true });
|
|
218
|
+
})();
|
|
219
|
+
}, Math.min(nextBoundary - now + REBUILD_BOUNDARY_BUFFER, MAX_REBUILD_TIMER_DELAY));
|
|
220
|
+
(timer as { unref?: () => void }).unref?.();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildBucket(account: string, bucketName: string, routing: RemoteConfig): LoadedBucket {
|
|
224
|
+
let folder = getBucketFolder(account, bucketName);
|
|
225
|
+
let selfIndexes = findSelfIndexes(routing, account, bucketName);
|
|
226
|
+
let selfEntries = selfIndexes.map(i => routing.sources[i] as HostedConfig);
|
|
227
|
+
let self = selectEntryAt(selfEntries, Date.now());
|
|
228
|
+
let selfIndex = -1;
|
|
229
|
+
if (self) {
|
|
230
|
+
selfIndex = routing.sources.indexOf(self);
|
|
231
|
+
}
|
|
232
|
+
let store: IBucketStore;
|
|
233
|
+
if (self?.rawDisk) {
|
|
234
|
+
store = new ArchivesDisk(folder);
|
|
235
|
+
} else {
|
|
236
|
+
// Our own disk is the base source; only the routing entries DOWNSTREAM from us (after our
|
|
237
|
+
// currently-valid entry) become synchronization sources. Upstream sources sync from us, so
|
|
238
|
+
// writing to or scanning them would just echo our own data back (and make our availability
|
|
239
|
+
// depend on theirs). A server NOT in the list has been removed from the bucket: it keeps
|
|
240
|
+
// its disk data (the config may re-add it), but stops all synchronization - no one
|
|
241
|
+
// contacts a removed source, and scanning/pushing as if we were still one would fight the
|
|
242
|
+
// real chain. The config-level validWindow rides on each ArchivesSource; the disk source
|
|
243
|
+
// shares our currently-valid entry's window (it holds our copy of the data).
|
|
244
|
+
let ownIndexes = new Set(selfIndexes);
|
|
245
|
+
// Our own disk gets no route filter: everything it holds is ours to index and serve
|
|
246
|
+
let sources: ArchivesSource[] = [{
|
|
247
|
+
source: new ArchivesDisk(folder),
|
|
248
|
+
validWindow: self?.validWindow || FULL_VALID_WINDOW,
|
|
249
|
+
}];
|
|
250
|
+
if (selfIndex === -1) {
|
|
251
|
+
console.log(`This server is not in the routing config for bucket ${account}/${bucketName}; keeping its data on disk but no longer synchronizing it`);
|
|
252
|
+
} else {
|
|
253
|
+
for (let i = selfIndex + 1; i < routing.sources.length; i++) {
|
|
254
|
+
let source = routing.sources[i];
|
|
255
|
+
if (typeof source === "string" || ownIndexes.has(i)) continue;
|
|
256
|
+
// Disjoint-shard sources never talk to each other; a partial overlap syncs only
|
|
257
|
+
// the intersection (scans ignore the rest, writes only send matching keys)
|
|
258
|
+
let sharedRoute = routeIntersection(self?.route, source.route);
|
|
259
|
+
if (!sharedRoute) continue;
|
|
260
|
+
// A bounded-cache server (noFullSync on our own entry) must not full-sync from
|
|
261
|
+
// ANY source, or its disk would fill regardless of the limit
|
|
262
|
+
sources.push({ source: createApiArchives(source), validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self?.noFullSync });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
store = new BlobStore(folder, sources, {
|
|
266
|
+
onIndexChanged: key => {
|
|
267
|
+
// Fires for our own routing writes AND routing files pulled in via synchronization
|
|
268
|
+
// (only ever newer ones, see IArchives.set) — either way, apply the new config
|
|
269
|
+
if (key !== ROUTING_FILE) return;
|
|
270
|
+
void scheduleRoutingReload(account, bucketName);
|
|
271
|
+
},
|
|
272
|
+
readerDiskLimit: self?.readerDiskLimit,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store };
|
|
276
|
+
scheduleWindowBoundaryRebuild(loaded);
|
|
277
|
+
return loaded;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
281
|
+
let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
|
|
282
|
+
if (!data) return undefined;
|
|
283
|
+
return buildBucket(account, bucketName, parseRoutingData(data));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
287
|
+
let key = `${account}/${bucketName}`;
|
|
288
|
+
let loaded = buckets.get(key);
|
|
289
|
+
if (!loaded) {
|
|
290
|
+
loaded = loadBucket(account, bucketName);
|
|
291
|
+
buckets.set(key, loaded);
|
|
292
|
+
// Missing buckets are not cached — they can be created at any time (guarding against a
|
|
293
|
+
// concurrent create having already replaced our entry)
|
|
294
|
+
void loaded.then(bucket => {
|
|
295
|
+
if (!bucket && buckets.get(key) === loaded) {
|
|
296
|
+
buckets.delete(key);
|
|
297
|
+
}
|
|
298
|
+
}, () => {
|
|
299
|
+
if (buckets.get(key) === loaded) {
|
|
300
|
+
buckets.delete(key);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return loaded;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Routing reloads are serialized per bucket, so concurrent writes/syncs can't rebuild the same
|
|
308
|
+
// store twice in parallel
|
|
309
|
+
const routingReloads = new Map<string, Promise<void>>();
|
|
310
|
+
function scheduleRoutingReload(account: string, bucketName: string, config?: { force?: boolean }): Promise<void> {
|
|
311
|
+
let key = `${account}/${bucketName}`;
|
|
312
|
+
let next = (routingReloads.get(key) || Promise.resolve())
|
|
313
|
+
.then(() => checkRoutingChanged(account, bucketName, config?.force))
|
|
314
|
+
.catch(e => console.error(`Reloading routing config for bucket ${key} failed:`, e));
|
|
315
|
+
routingReloads.set(key, next);
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function checkRoutingChanged(account: string, bucketName: string, force?: boolean): Promise<void> {
|
|
320
|
+
let key = `${account}/${bucketName}`;
|
|
321
|
+
let loaded = await buckets.get(key);
|
|
322
|
+
if (!loaded) return;
|
|
323
|
+
// get() cache-reads the file onto our disk when a remote source holds it, so the bucket still
|
|
324
|
+
// exists after a restart
|
|
325
|
+
let data = await loaded.store.get(ROUTING_FILE);
|
|
326
|
+
if (!data) return;
|
|
327
|
+
let routing = parseRoutingData(data);
|
|
328
|
+
if (!force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
329
|
+
if (force) {
|
|
330
|
+
console.log(`Rebuilding the store for bucket ${key} (a valid window boundary passed)`);
|
|
331
|
+
} else {
|
|
332
|
+
console.log(`Routing config changed for bucket ${key}, rebuilding its store`);
|
|
333
|
+
}
|
|
334
|
+
if (loaded.store instanceof BlobStore) {
|
|
335
|
+
await loaded.store.dispose();
|
|
336
|
+
}
|
|
337
|
+
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing)));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Per-write options are evaluated at the WRITE's time (and the key's route), not the current
|
|
341
|
+
// config: the write time is stamped exactly once (at the first node that receives the write) and
|
|
342
|
+
// propagates with the data, so every node resolves the same self entry - and the same options.
|
|
343
|
+
function getWriteConfig(bucket: LoadedBucket, writeTime: number, route: number): { fast?: boolean; writeDelay?: number } {
|
|
344
|
+
let self = selectEntryAt(bucket.selfEntries, writeTime, route);
|
|
345
|
+
return { fast: self?.fast, writeDelay: self?.writeDelay };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function assertMutable(bucket: LoadedBucket, filePath: string, writeTime: number): Promise<void> {
|
|
349
|
+
let self = selectEntryAt(bucket.selfEntries, writeTime, getRoute(filePath));
|
|
350
|
+
if (!self?.immutable) return;
|
|
351
|
+
if (await bucket.store.getInfo(filePath)) {
|
|
352
|
+
throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} is immutable (at write time ${writeTime}) and ${JSON.stringify(filePath)} already exists, so it cannot be written to`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Routing writes are serialized per bucket, so two concurrent creates can't both build a store
|
|
357
|
+
// (the loser's store would leak with its sync loops running forever)
|
|
358
|
+
const routingWrites = new Map<string, Promise<void>>();
|
|
359
|
+
|
|
360
|
+
async function writeRoutingConfig(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
361
|
+
let key = `${account}/${bucketName}`;
|
|
362
|
+
// Validates before storing anything, so a bad config can't brick the bucket
|
|
363
|
+
let incoming = parseRoutingData(data);
|
|
364
|
+
let loaded = await getLoadedBucket(account, bucketName);
|
|
365
|
+
if (!loaded) {
|
|
366
|
+
await new ArchivesDisk(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
|
|
367
|
+
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, incoming)));
|
|
368
|
+
console.log(`Created bucket ${key}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// A changed config must carry a higher version than the one currently stored — otherwise two
|
|
372
|
+
// clients with different configs at the same version would keep clobbering each other. Same
|
|
373
|
+
// content at any version is a harmless no-op and is allowed through.
|
|
374
|
+
if (JSON.stringify(incoming) !== loaded.routingJSON && getConfigVersion(incoming) <= getConfigVersion(loaded.routing)) {
|
|
375
|
+
throw new Error(`Cannot update routing config for bucket ${key}: the new config differs from the current one but its version (${getConfigVersion(incoming)}) is not greater than the current version (${getConfigVersion(loaded.routing)}). Increment the version to update it. Current: ${loaded.routingJSON}. Attempted: ${JSON.stringify(incoming)}`);
|
|
376
|
+
}
|
|
377
|
+
// Routing writes bypass fast mode (the config must apply immediately and survive a crash)
|
|
378
|
+
// and ignore immutable (the routing file must always stay updatable). An older lastModified
|
|
379
|
+
// no-ops inside set, in which case the reload below sees no change.
|
|
380
|
+
await loaded.store.set(ROUTING_FILE, data, { lastModified: config?.lastModified });
|
|
381
|
+
await scheduleRoutingReload(account, bucketName);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
385
|
+
assertWritesAllowed();
|
|
386
|
+
if (filePath === ROUTING_FILE) {
|
|
387
|
+
let key = `${account}/${bucketName}`;
|
|
388
|
+
let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
|
|
389
|
+
// The chain must survive a failed write, so the stored link swallows the error (the caller
|
|
390
|
+
// still gets it from run)
|
|
391
|
+
routingWrites.set(key, run.then(() => { }, () => { }));
|
|
392
|
+
return await run;
|
|
393
|
+
}
|
|
394
|
+
let loaded = await getLoadedBucket(account, bucketName);
|
|
395
|
+
if (!loaded) {
|
|
396
|
+
throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
397
|
+
}
|
|
398
|
+
// The write time is determined exactly once, HERE at the first node that receives the write;
|
|
399
|
+
// it propagates as lastModified, so every node evaluates the bucket options at the same instant
|
|
400
|
+
let writeTime = config?.lastModified || Date.now();
|
|
401
|
+
// Only freshly-stamped writes are checked: a write with an explicit lastModified is
|
|
402
|
+
// synchronization/backfill (old data legitimately lands on sources whose window has moved on),
|
|
403
|
+
// but a fresh write reaching us outside our windows/routes means the client resolved its
|
|
404
|
+
// target from a stale view - it must re-resolve and retry at the correct source.
|
|
405
|
+
let route = getRoute(filePath);
|
|
406
|
+
if (!config?.lastModified && loaded.selfEntries.length) {
|
|
407
|
+
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] - WRITE_PAST_WINDOW_GRACE && writeTime <= x.validWindow[1] + WRITE_PAST_WINDOW_GRACE);
|
|
408
|
+
if (!timeValid.length) {
|
|
409
|
+
throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This server is not a valid write target at ${writeTime} for bucket ${account}/${bucketName} (our valid windows: ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))}). Re-resolve the currently valid source and retry.`);
|
|
410
|
+
}
|
|
411
|
+
if (!timeValid.some(x => routeContains(x.route, route))) {
|
|
412
|
+
throw new Error(`${STORAGE_WRONG_ROUTE} This server does not handle route ${route} (key ${JSON.stringify(filePath)}) for bucket ${account}/${bucketName} (our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}). Re-resolve the source for this key and retry.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
await assertMutable(loaded, filePath, writeTime);
|
|
416
|
+
await loaded.store.set(filePath, data, { ...getWriteConfig(loaded, writeTime, route), lastModified: writeTime });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void> {
|
|
420
|
+
if (filePath === ROUTING_FILE) {
|
|
421
|
+
throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
|
|
422
|
+
}
|
|
423
|
+
let loaded = await getLoadedBucket(account, bucketName);
|
|
424
|
+
if (!loaded) return;
|
|
425
|
+
await loaded.store.del(filePath, getWriteConfig(loaded, Date.now(), getRoute(filePath)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── local IArchives access ──
|
|
429
|
+
|
|
430
|
+
const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
|
|
431
|
+
|
|
432
|
+
// The in-process IArchives for a bucket hosted by THIS server — used when a RemoteConfig source
|
|
433
|
+
// URL points at ourselves, so we don't talk to ourselves over HTTPS. One singleton per bucket.
|
|
434
|
+
class ArchivesLocalBucket implements IArchives {
|
|
435
|
+
constructor(private account: string, private bucketName: string) { }
|
|
436
|
+
|
|
437
|
+
private async getBucket(): Promise<LoadedBucket | undefined> {
|
|
438
|
+
return await getLoadedBucket(this.account, this.bucketName);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
public getDebugName() {
|
|
442
|
+
return `localBucket/${this.account}/${this.bucketName}`;
|
|
443
|
+
}
|
|
444
|
+
public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
|
|
445
|
+
let result = await this.get2(fileName, config);
|
|
446
|
+
return result && result.data || undefined;
|
|
447
|
+
}
|
|
448
|
+
public async get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
449
|
+
let bucket = await this.getBucket();
|
|
450
|
+
if (!bucket) return undefined;
|
|
451
|
+
return await bucket.store.get2(fileName, config);
|
|
452
|
+
}
|
|
453
|
+
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
454
|
+
await writeBucketFile(this.account, this.bucketName, fileName, data, config);
|
|
455
|
+
}
|
|
456
|
+
public async del(fileName: string): Promise<void> {
|
|
457
|
+
await deleteBucketFile(this.account, this.bucketName, fileName);
|
|
458
|
+
}
|
|
459
|
+
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
460
|
+
assertWritesAllowed();
|
|
461
|
+
let bucket = await this.getBucket();
|
|
462
|
+
if (!bucket) {
|
|
463
|
+
throw new Error(`Bucket ${this.account}/${this.bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
464
|
+
}
|
|
465
|
+
await assertMutable(bucket, config.path, Date.now());
|
|
466
|
+
let id = await bucket.store.startLargeUpload();
|
|
467
|
+
try {
|
|
468
|
+
while (true) {
|
|
469
|
+
let data = await config.getNextData();
|
|
470
|
+
if (!data) break;
|
|
471
|
+
for (let offset = 0; offset < data.length; offset += LARGE_FILE_PART_SIZE) {
|
|
472
|
+
await bucket.store.appendLargeUpload(id, data.subarray(offset, offset + LARGE_FILE_PART_SIZE));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
await bucket.store.finishLargeUpload(id, config.path);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
await bucket.store.cancelLargeUpload(id);
|
|
478
|
+
throw e;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
482
|
+
let bucket = await this.getBucket();
|
|
483
|
+
if (!bucket) return undefined;
|
|
484
|
+
return await bucket.store.getInfo(fileName);
|
|
485
|
+
}
|
|
486
|
+
public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
487
|
+
let bucket = await this.getBucket();
|
|
488
|
+
if (!bucket) return [];
|
|
489
|
+
return await bucket.store.findInfo(prefix, config);
|
|
490
|
+
}
|
|
491
|
+
public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
|
|
492
|
+
return (await this.findInfo(prefix, config)).map(x => x.path);
|
|
493
|
+
}
|
|
494
|
+
public async getURL(filePath: string): Promise<string> {
|
|
495
|
+
let { domain, port } = getStorageServerConfig();
|
|
496
|
+
return buildFileUrl(`https://${domain}:${port}/file/${encodeURIComponent(this.account)}/${encodeURIComponent(this.bucketName)}`, filePath);
|
|
497
|
+
}
|
|
498
|
+
public async getConfig(): Promise<ArchivesConfig> {
|
|
499
|
+
let bucket = await this.getBucket();
|
|
500
|
+
// Missing buckets say true, matching what they become once created (the default store type)
|
|
501
|
+
let progress = bucket?.store.getSyncProgress?.();
|
|
502
|
+
return {
|
|
503
|
+
supportsChangesAfter: !bucket || !!bucket.store.getChangesAfter,
|
|
504
|
+
remoteConfig: bucket?.routing,
|
|
505
|
+
index: progress?.index,
|
|
506
|
+
indexSources: progress?.sources,
|
|
507
|
+
readerDiskLimit: progress?.readerDiskLimit,
|
|
508
|
+
syncing: progress?.syncing,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
public async hasWriteAccess(): Promise<boolean> {
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
|
|
515
|
+
let bucket = await this.getBucket();
|
|
516
|
+
if (!bucket) return [];
|
|
517
|
+
if (!bucket.store.getChangesAfter) {
|
|
518
|
+
throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getChangesAfter (rawDisk buckets have no index)`);
|
|
519
|
+
}
|
|
520
|
+
return await bucket.store.getChangesAfter(time);
|
|
521
|
+
}
|
|
522
|
+
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
523
|
+
let bucket = await this.getBucket();
|
|
524
|
+
if (!bucket) return { allScansComplete: true, indexSize: 0, sources: [] };
|
|
525
|
+
if (!bucket.store.getSyncStatus) {
|
|
526
|
+
throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
|
|
527
|
+
}
|
|
528
|
+
return await bucket.store.getSyncStatus();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const localArchives = new Map<string, IArchives>();
|
|
533
|
+
export function getLocalArchives(account: string, bucketName: string): IArchives {
|
|
534
|
+
let key = `${account}/${bucketName}`;
|
|
535
|
+
let existing = localArchives.get(key);
|
|
536
|
+
if (existing) return existing;
|
|
537
|
+
let archives = new ArchivesLocalBucket(account, bucketName);
|
|
538
|
+
localArchives.set(key, archives);
|
|
539
|
+
return archives;
|
|
540
|
+
}
|
package/testsite/server.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
process.env.NODE_ENV = "production";
|
|
2
|
-
import os from "os";
|
|
3
2
|
import path from "path";
|
|
4
3
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
5
4
|
import { RequireController } from "socket-function/require/RequireController";
|
|
@@ -30,7 +29,6 @@ async function main() {
|
|
|
30
29
|
await hostServer({
|
|
31
30
|
domain: DOMAIN,
|
|
32
31
|
port: PORT,
|
|
33
|
-
cloudflareApiTokenPath: os.homedir() + "/vidgridweb.com.key",
|
|
34
32
|
setDNSRecord: true,
|
|
35
33
|
});
|
|
36
34
|
}
|
package/yarn.lock
CHANGED
|
@@ -1944,10 +1944,10 @@ slash@^3.0.0:
|
|
|
1944
1944
|
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
|
1945
1945
|
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
|
|
1946
1946
|
|
|
1947
|
-
socket-function@^1.2.
|
|
1948
|
-
version "1.2.
|
|
1949
|
-
resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.2.
|
|
1950
|
-
integrity sha512-
|
|
1947
|
+
socket-function@^1.2.16:
|
|
1948
|
+
version "1.2.16"
|
|
1949
|
+
resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.2.16.tgz#6ad7fc1971202d9768582b0212f086675fba61b5"
|
|
1950
|
+
integrity sha512-o3GATr5Fa+496StsPWJIb0yytY/XRMZjJQ4cgb1LOQIfFv7uJUJ3NZtcldMeN/kOmqI0hBaMvhRLC3VHke9awA==
|
|
1951
1951
|
dependencies:
|
|
1952
1952
|
"@types/pako" "^2.0.3"
|
|
1953
1953
|
"@types/ws" "^8.5.3"
|
|
@@ -1957,7 +1957,7 @@ socket-function@^1.2.4:
|
|
|
1957
1957
|
preact "10.24.3"
|
|
1958
1958
|
typedev "^0.4.0"
|
|
1959
1959
|
typenode "^6.0.1"
|
|
1960
|
-
ws "^8.
|
|
1960
|
+
ws "^8.21.0"
|
|
1961
1961
|
optionalDependencies:
|
|
1962
1962
|
lmdb "^3.5.1"
|
|
1963
1963
|
|
|
@@ -2177,11 +2177,16 @@ wrappy@1:
|
|
|
2177
2177
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
|
2178
2178
|
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
|
2179
2179
|
|
|
2180
|
-
ws@^8.
|
|
2180
|
+
ws@^8.18.3:
|
|
2181
2181
|
version "8.18.3"
|
|
2182
2182
|
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
|
|
2183
2183
|
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
|
|
2184
2184
|
|
|
2185
|
+
ws@^8.21.0:
|
|
2186
|
+
version "8.21.1"
|
|
2187
|
+
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586"
|
|
2188
|
+
integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==
|
|
2189
|
+
|
|
2185
2190
|
y18n@^4.0.0:
|
|
2186
2191
|
version "4.0.3"
|
|
2187
2192
|
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|