sliftutils 1.7.32 → 1.7.34

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 (116) hide show
  1. package/builders/extensionBuild.ts +1 -1
  2. package/bundler/bundleEntry.ts +1 -3
  3. package/bundler/bundler.ts +2 -4
  4. package/bundler/sourceMaps.ts +1 -2
  5. package/examplestorage/browser.tsx +1 -2
  6. package/examplestorage/exampleserver.ts +1 -2
  7. package/index.d.ts +134 -102
  8. package/misc/dist/environment.ts.cache +2 -2
  9. package/misc/dist/getSecret.ts.cache +2 -2
  10. package/misc/dist/strings.ts.cache +2 -2
  11. package/misc/dist/zip.ts.cache +2 -2
  12. package/misc/https/dist/certs.ts.cache +2 -2
  13. package/misc/https/dist/dns.ts.cache +7 -16
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/dns.d.ts +1 -4
  16. package/misc/https/dns.ts +5 -14
  17. package/misc/https/hostServer.d.ts +2 -0
  18. package/misc/https/hostServer.ts +6 -7
  19. package/misc/https/httpsCerts.ts +4 -10
  20. package/misc/openrouter.ts +1 -2
  21. package/package.json +1 -1
  22. package/render-utils/FullscreenModal.tsx +1 -4
  23. package/render-utils/GenericFormat.tsx +2 -8
  24. package/render-utils/Input.tsx +5 -13
  25. package/render-utils/InputLabel.tsx +4 -6
  26. package/render-utils/InputPicker.tsx +1 -2
  27. package/render-utils/URLParam.ts +2 -3
  28. package/render-utils/dist/observer.tsx.cache +2 -2
  29. package/render-utils/mobxTyped.ts +2 -3
  30. package/render-utils/niceStringify.ts +2 -4
  31. package/storage/ArchivesDisk.ts +4 -14
  32. package/storage/BulkDatabase2/BulkDatabase2.d.ts +1 -2
  33. package/storage/BulkDatabase2/BulkDatabase2.ts +4 -11
  34. package/storage/BulkDatabase2/BulkDatabaseBase.ts +42 -114
  35. package/storage/BulkDatabase2/BulkDatabaseFormat.ts +31 -91
  36. package/storage/BulkDatabase2/BulkDatabaseMerge.ts +19 -57
  37. package/storage/BulkDatabase2/BulkDatabaseReader.ts +4 -12
  38. package/storage/BulkDatabase2/LoadedIndex.ts +6 -18
  39. package/storage/BulkDatabase2/WriteOverlay.ts +3 -8
  40. package/storage/BulkDatabase2/blockCache.ts +12 -28
  41. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +40 -103
  42. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +19 -50
  43. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +18 -50
  44. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +7 -15
  45. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +9 -21
  46. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +6 -11
  47. package/storage/BulkDatabase2/dist/blockCache.ts.cache +15 -31
  48. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +10 -29
  49. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +10 -18
  50. package/storage/BulkDatabase2/dist/streamLog.ts.cache +17 -43
  51. package/storage/BulkDatabase2/dist/syncClient.ts.cache +11 -27
  52. package/storage/BulkDatabase2/mergeLock.ts +7 -26
  53. package/storage/BulkDatabase2/mergeMarkers.ts +7 -15
  54. package/storage/BulkDatabase2/streamLog.ts +14 -40
  55. package/storage/BulkDatabase2/syncClient.ts +8 -24
  56. package/storage/FileFolderAPI.tsx +28 -75
  57. package/storage/IArchives.d.ts +3 -3
  58. package/storage/IArchives.ts +21 -50
  59. package/storage/IStorage.ts +2 -4
  60. package/storage/PendingManager.tsx +2 -3
  61. package/storage/TransactionStorage.ts +11 -24
  62. package/storage/backblaze.ts +12 -43
  63. package/storage/dist/ArchivesDisk.ts.cache +7 -17
  64. package/storage/dist/FileFolderAPI.tsx.cache +25 -63
  65. package/storage/dist/IArchives.ts.cache +10 -20
  66. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  67. package/storage/dist/JSONStorage.ts.cache +2 -2
  68. package/storage/dist/PendingManager.tsx.cache +5 -6
  69. package/storage/dist/TransactionStorage.ts.cache +14 -27
  70. package/storage/dist/backblaze.ts.cache +15 -46
  71. package/storage/dist/embeddingFormats.ts.cache +11 -24
  72. package/storage/dist/fileSystemPointer.ts.cache +4 -7
  73. package/storage/dist/remoteFileStorage.ts.cache +17 -36
  74. package/storage/embeddingFormats.ts +11 -27
  75. package/storage/fileSystemPointer.ts +2 -7
  76. package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +8 -18
  77. package/storage/proxydatabase/dist/transactionSet.ts.cache +4 -7
  78. package/storage/proxydatabase/inMemoryDatabase.ts +1 -3
  79. package/storage/proxydatabase/ivfEmbeddingDatabase.ts +8 -23
  80. package/storage/proxydatabase/transactionSet.ts +2 -5
  81. package/storage/remoteFileServer.ts +20 -48
  82. package/storage/remoteFileStorage.ts +14 -33
  83. package/storage/remoteStorage/ArchivesRemote.d.ts +1 -4
  84. package/storage/remoteStorage/ArchivesRemote.ts +11 -26
  85. package/storage/remoteStorage/ArchivesUrl.ts +2 -4
  86. package/storage/remoteStorage/accessPage.tsx +1 -5
  87. package/storage/remoteStorage/blobStore.d.ts +5 -13
  88. package/storage/remoteStorage/blobStore.ts +52 -130
  89. package/storage/remoteStorage/createArchives.d.ts +22 -14
  90. package/storage/remoteStorage/createArchives.ts +54 -188
  91. package/storage/remoteStorage/deployTakeover.d.ts +18 -32
  92. package/storage/remoteStorage/deployTakeover.ts +70 -384
  93. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +12 -24
  94. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +5 -7
  95. package/storage/remoteStorage/dist/blobStore.ts.cache +47 -117
  96. package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
  97. package/storage/remoteStorage/dist/createArchives.ts.cache +58 -189
  98. package/storage/remoteStorage/dist/deployTakeover.ts.cache +81 -387
  99. package/storage/remoteStorage/dist/intermediateSources.ts.cache +119 -0
  100. package/storage/remoteStorage/dist/remoteConfig.ts.cache +36 -24
  101. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +20 -40
  102. package/storage/remoteStorage/dist/storageClientController.ts.cache +4 -6
  103. package/storage/remoteStorage/dist/storageController.ts.cache +37 -74
  104. package/storage/remoteStorage/dist/storageServerState.ts.cache +345 -188
  105. package/storage/remoteStorage/grantAccessCli.ts +1 -3
  106. package/storage/remoteStorage/intermediateSources.d.ts +21 -0
  107. package/storage/remoteStorage/intermediateSources.ts +117 -0
  108. package/storage/remoteStorage/remoteConfig.ts +33 -20
  109. package/storage/remoteStorage/sourceWrapper.d.ts +8 -19
  110. package/storage/remoteStorage/sourceWrapper.ts +19 -41
  111. package/storage/remoteStorage/storageClientController.ts +1 -3
  112. package/storage/remoteStorage/storageController.d.ts +7 -6
  113. package/storage/remoteStorage/storageController.ts +36 -82
  114. package/storage/remoteStorage/storageServer.ts +20 -34
  115. package/storage/remoteStorage/storageServerState.d.ts +41 -5
  116. package/storage/remoteStorage/storageServerState.ts +355 -216
@@ -10,39 +10,27 @@ import { timeInMinute } from "socket-function/src/misc";
10
10
  import { getCommonName, getPublicIdentifier, getOwnMachineId, verify, verifyMachineIdForPublicKey } from "../../misc/https/certs";
11
11
  import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, IMMUTABLE_CACHE_TIME } from "../IArchives";
12
12
  import { ROUTING_FILE } from "./remoteConfig";
13
- import { getTakeoverStamp } from "./deployTakeover";
14
13
  import {
15
14
  getStorageServerConfig, getTrust, getRequests, getLoadedBucket, writeBucketFile,
16
15
  deleteBucketFile, assertWritesAllowed, assertMutable, LoadedBucket,
17
- getBucketConfig, listAccountBuckets, ServerBucketInfo,
16
+ getBucketConfig, listAccountBuckets, ServerBucketInfo, clearAccountWriteStats,
17
+ getActiveBucket, activateBucket, ActiveBucketInfo,
18
18
  } from "./storageServerState";
19
19
  import { StorageClientController } from "./storageClientController";
20
20
 
21
- // The remote storage server's API. Authentication uses certs.ts machine identities: a client
22
- // proves it owns its machine key by signing a timestamped token (bound to this server, so tokens
23
- // can't be replayed elsewhere), and the server then trusts that connection as that machineId.
24
- // Access to an account is granted to specific machineIds, via a command line command run on the
25
- // storage machine (see storageServer.ts).
26
- //
27
- // There is no bucket-creation API: a bucket exists iff its routing config (ROUTING_FILE) exists,
28
- // and writing that file creates or reconfigures the bucket (see storageServerState.ts). Reads of
29
- // nonexistent buckets return undefined / empty, same as reads of nonexistent files.
30
-
31
21
  export const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
32
22
  export const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
33
- // Error markers, so clients can identify these failures inside error messages
34
23
  export const STORAGE_NOT_AUTHENTICATED = "REMOTE_STORAGE_NOT_AUTHENTICATED_cf2f7b1e";
35
24
  export const STORAGE_ACCESS_DENIED = "REMOTE_STORAGE_ACCESS_DENIED_9d81a4c0";
36
25
 
37
26
  const AUTH_TIME_WINDOW = timeInMinute * 10;
27
+ const ACCESS_CHECK_SLOW_TIME = 50;
38
28
  const MAX_SESSIONS = 100 * 1000;
39
29
  const MAX_REQUESTS_PER_IP = 50;
40
30
 
41
31
  export type AuthTokenData = {
42
32
  purpose: string;
43
33
  time: number;
44
- // The address the client dialed. The port varies freely (deploy takeovers serve on alternate
45
- // ports); only the domain is security-relevant, as it is what the cert binds to.
46
34
  server: string;
47
35
  };
48
36
  export type AuthToken = {
@@ -67,18 +55,10 @@ export type AccessState = {
67
55
  machineId: string;
68
56
  ip: string;
69
57
  hasAccess: boolean;
70
- // A single ssh command, runnable from anywhere, that runs the grantAccess CLI on the storage
71
- // machine to grant the caller's own pending request. Only set when the caller has a pending
72
- // request (so an already-trusted caller has no need for it).
73
58
  grantAccessCommand?: string;
74
- // Only the machines that ALREADY have access. Pending requests are NEVER listed here — showing
75
- // them would let a trusted user accidentally approve a random request. Callers see pending
76
- // requests only by explicitly typing an IP into listRequestsForIP.
77
59
  trustedMachines?: TrustRecord[];
78
60
  };
79
61
 
80
- // callerNodeId -> authenticated machineId. Connections are long-lived websockets, so a session
81
- // lasts until the connection drops (clients re-authenticate on reconnect).
82
62
  const sessions = new Map<string, string>();
83
63
 
84
64
  const CONTENT_TYPES: { [ext: string]: string } = {
@@ -100,15 +80,11 @@ function assertValidPath(path: string) {
100
80
  if (!path || path.startsWith("/") || path.endsWith("/") || path.includes("//") || path.includes("\\") || path.includes("\x00")) {
101
81
  throw new Error(`Invalid path ${JSON.stringify(path.slice(0, 200))}, paths cannot be empty, start or end with /, or contain //, backslashes, or null characters`);
102
82
  }
103
- // Paths are one-to-one with files on disk, so . and .. segments would escape the store folder
104
83
  if (path.split("/").some(part => part === "." || part === "..")) {
105
84
  throw new Error(`Invalid path ${JSON.stringify(path.slice(0, 200))}, paths cannot contain . or .. segments`);
106
85
  }
107
86
  }
108
87
 
109
- // Every client pings us (immediately on connect, then continuously), so pings tell us exactly who
110
- // our currently-connected clients are. Client nodeIds never reconnect (a reconnect is a NEW
111
- // nodeId), so the disconnect callback fully cleans an entry up.
112
88
  const connectedClients = new Set<string>();
113
89
  function trackCaller(): void {
114
90
  let nodeId = SocketFunction.getCaller().nodeId;
@@ -119,15 +95,10 @@ function trackCaller(): void {
119
95
  });
120
96
  }
121
97
 
122
- /** Called by storageServerState the moment any routing config is applied - clients must never
123
- * have to wait for a poll to learn the topology changed. */
124
98
  export function broadcastRoutingChanged(): void {
125
99
  console.log(`Broadcasting routing config change to ${connectedClients.size} connected clients`);
126
100
  for (let nodeId of [...connectedClients]) {
127
- void StorageClientController.nodes[nodeId].routingConfigChanged().catch(() => {
128
- // The client is gone (or too old to know the controller); the disconnect callback
129
- // cleans the registry, nothing to do here
130
- });
101
+ void StorageClientController.nodes[nodeId].routingConfigChanged().catch(() => { });
131
102
  }
132
103
  }
133
104
 
@@ -165,8 +136,6 @@ async function requireAccess(account: string): Promise<string> {
165
136
  return machineId;
166
137
  }
167
138
 
168
- // A single command, runnable from anywhere, that sshes into the storage machine and runs the
169
- // grantAccess CLI there
170
139
  function getGrantAccessCommand(requestId: string): string {
171
140
  let { sshTarget, serverCommand } = getStorageServerConfig();
172
141
  return `ssh ${sshTarget} '${serverCommand} --requestId ${requestId}'`;
@@ -179,23 +148,14 @@ async function getBucket(account: string, bucketName: string): Promise<LoadedBuc
179
148
  }
180
149
 
181
150
  class RemoteStorageControllerBase {
182
- // Latency measurement (see SourceWrapper's pinging); no auth, it measures the transport. The
183
- // takeover stamp piggybacks on it so every connected client learns of a deploy takeover
184
- // within one ping interval. Pings also tell us who our clients ARE - the trackCaller
185
- // registry is what routing-change broadcasts push to.
186
- async ping(): Promise<{ takeover?: string }> {
151
+ async ping(): Promise<{}> {
187
152
  trackCaller();
188
- return { takeover: getTakeoverStamp() };
153
+ return {};
189
154
  }
190
155
 
191
- // Proves the caller owns the machine key for the machineId in its certificate. The signature
192
- // must be fresh and bound to this server, so it cannot be replayed to (or from) other servers.
193
156
  async authenticate(token: AuthToken): Promise<{ machineId: string; ip: string }> {
194
157
  let { domain, port } = getStorageServerConfig();
195
158
  let caller = SocketFunction.getCaller();
196
- // First establish that the signature signs the data exactly as the client sent it, THEN
197
- // validate the data's fields. The two steps must never be mixed - reconstructing the
198
- // payload from expectations conflates "is this signed" with "is this acceptable".
199
159
  verify(token.certPem, token.signature, token.data);
200
160
  let { purpose, time, server } = token.data;
201
161
  if (purpose !== STORAGE_AUTH_PURPOSE) {
@@ -204,8 +164,6 @@ class RemoteStorageControllerBase {
204
164
  if (Math.abs(Date.now() - time) > AUTH_TIME_WINDOW) {
205
165
  throw new Error(`Auth token time is too far from the server time (token ${time}, server ${Date.now()}, allowed drift ${AUTH_TIME_WINDOW}ms)`);
206
166
  }
207
- // Clients sign the address they dialed. The port varies freely (deploy takeovers serve on
208
- // alternate ports); the domain is the replay boundary, so only it must match us.
209
167
  let tokenDomain = server.split(":")[0];
210
168
  if (tokenDomain !== domain) {
211
169
  throw new Error(`Auth token is for server ${JSON.stringify(server)}, but this server is ${JSON.stringify(`${domain}:${port}`)}`);
@@ -223,8 +181,6 @@ class RemoteStorageControllerBase {
223
181
  return { machineId, ip: getCallerIP() };
224
182
  }
225
183
 
226
- // Records that the calling machine wants access to an account. Requests are kept per requesting
227
- // IP, so the storage machine's admin can list them with --listAccess <ip> and grant one.
228
184
  async requestAccess(account: string): Promise<{ machineId: string; ip: string; requestId: string; grantAccessCommand: string }> {
229
185
  assertValidName(account, "account");
230
186
  let machineId = getCallerMachineId();
@@ -275,20 +231,12 @@ class RemoteStorageControllerBase {
275
231
  return result;
276
232
  }
277
233
 
278
- // Callable by any machine that has access to `account`. Returns pending access requests for the
279
- // account that come from EXACTLY `ip`. Callers must type in an IP explicitly — the server never
280
- // volunteers a list of requesting IPs, so a trusted user can't accidentally approve a random
281
- // request from a machine they didn't mean to trust.
282
234
  async listRequestsForIP(account: string, ip: string): Promise<AccessRequest[]> {
283
235
  let requests = await getRequests();
284
236
  return (await requests.get(ip) || []).filter(x => x.account === account);
285
237
  }
286
238
 
287
- // Callable by any machine that has access to the request's account (or by the storage-machine
288
- // admin). Grants the requested access; the caller must supply the specific requestId, which they
289
- // only get by explicitly looking up requests for a specific IP.
290
239
  async grantAccess(requestId: string): Promise<TrustRecord> {
291
- // Must capture in the synchronous phase — SocketFunction.getCaller() only works before any await.
292
240
  let callerMachineId = getCallerMachineId();
293
241
  let trust = await getTrust();
294
242
  let requests = await getRequests();
@@ -311,9 +259,6 @@ class RemoteStorageControllerBase {
311
259
  throw new Error(`No access request found with id ${JSON.stringify(requestId)}. It may have already been granted or expired.`);
312
260
  }
313
261
 
314
- // Admin (must be run from the storage machine itself, which shares the server's machineId).
315
- // Only returns requests for the given IP, so you cannot accidentally grant a request from an
316
- // IP you didn't explicitly type in.
317
262
  async adminListRequests(ip: string): Promise<AccessRequest[]> {
318
263
  let requests = await getRequests();
319
264
  return await requests.get(ip) || [];
@@ -350,8 +295,6 @@ class RemoteStorageControllerBase {
350
295
  async set(account: string, bucketName: string, path: string, data: Buffer, lastModified?: number): Promise<void> {
351
296
  assertValidName(bucketName, "bucket name");
352
297
  assertValidPath(path);
353
- // Handles bucket creation (writes of ROUTING_FILE), reconfiguration, fast mode, and
354
- // immutability — see storageServerState.ts
355
298
  await writeBucketFile(account, bucketName, path, Buffer.from(data), { lastModified });
356
299
  }
357
300
  async del(account: string, bucketName: string, path: string): Promise<void> {
@@ -370,7 +313,6 @@ class RemoteStorageControllerBase {
370
313
  if (!bucket) return [];
371
314
  return await bucket.store.findInfo(prefix, config);
372
315
  }
373
- // Fast (served from the store's BulkDatabase2 index, not a scan) — see IArchives.getChangesAfter
374
316
  async getChangesAfter(account: string, bucketName: string, time: number): Promise<ArchiveFileInfo[]> {
375
317
  let bucket = await getBucket(account, bucketName);
376
318
  if (!bucket) return [];
@@ -381,17 +323,33 @@ class RemoteStorageControllerBase {
381
323
  }
382
324
  async getArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
383
325
  let bucket = await getBucket(account, bucketName);
384
- // Missing buckets say true, matching what they become once created (the default store type)
385
326
  if (!bucket) return { supportsChangesAfter: true };
386
327
  return getBucketConfig(bucket);
387
328
  }
388
- /** Every bucket the account has on this host - active and inactive - with each bucket's
389
- * configuration. Inactive buckets stay inactive (their routing is read straight off disk). */
390
329
  async listBuckets(account: string): Promise<ServerBucketInfo[]> {
391
- return await listAccountBuckets(account);
330
+ let start = Date.now();
331
+ try {
332
+ return await listAccountBuckets(account);
333
+ } finally {
334
+ // The access hook (and the storage it initializes) runs before this, so a large gap between this and the caller's timing is the hook
335
+ console.log(`listBuckets(${account}) call body took ${Date.now() - start}ms`);
336
+ }
337
+ }
338
+ /** The live, in-memory state of one bucket, or a string saying why it is unavailable. Answered without touching the disk, so it is cheap - but only works while the bucket is loaded here. */
339
+ async getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
340
+ assertValidName(bucketName, "bucket name");
341
+ return await getActiveBucket(account, bucketName);
342
+ }
343
+ /** Loads a bucket that exists on this server into memory (starting its synchronization) and returns its live state, or a string saying why it could not be loaded. */
344
+ async activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
345
+ assertValidName(bucketName, "bucket name");
346
+ return await activateBucket(account, bucketName);
347
+ }
348
+ /** Zeroes the write statistics listBuckets reports, for every bucket in the account. */
349
+ async clearWriteStats(account: string): Promise<{ clearedBuckets: number }> {
350
+ assertValidName(account, "account");
351
+ return { clearedBuckets: await clearAccountWriteStats(account) };
392
352
  }
393
- /** Walks the whole index for exact totals (overall and per holding source) - more expensive
394
- * than the maintained counters that getArchivesConfig returns, but immune to counter drift. */
395
353
  async getIndexInfo(account: string, bucketName: string): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
396
354
  let bucket = await getBucket(account, bucketName);
397
355
  if (!bucket || !bucket.store.computeIndexTotals) return undefined;
@@ -408,7 +366,6 @@ class RemoteStorageControllerBase {
408
366
 
409
367
  async startLargeFile(account: string, bucketName: string, path: string): Promise<string> {
410
368
  assertWritesAllowed();
411
- // Validates now, so the upload doesn't fail at the end
412
369
  assertValidPath(path);
413
370
  let bucket = await getBucket(account, bucketName);
414
371
  if (!bucket) {
@@ -445,11 +402,7 @@ class RemoteStorageControllerBase {
445
402
  await bucket.store.cancelLargeUpload(uploadId);
446
403
  }
447
404
 
448
- // The server's single default HTTP route. /file/<account>/<bucketName>/<path> serves files from
449
- // public buckets over plain GETs (see IArchives.getURL); every other path serves the access
450
- // page (via RequireController.requireHTML — the path is the account name, see accessPage.tsx).
451
405
  async httpEntry(config?: { requireCalls?: string[]; cacheTime?: number }): Promise<Buffer> {
452
- // Both are keyed by the current call and must be captured synchronously, before any await
453
406
  let caller = SocketFunction.getCaller();
454
407
  let request = getCurrentHTTPRequest();
455
408
  let pathname = new URL(request?.url || "/", "https://localhost").pathname;
@@ -476,8 +429,6 @@ class RemoteStorageControllerBase {
476
429
  if (!bucket.self?.public) {
477
430
  throw new Error(`Bucket ${account}/${bucketName} is not public, so its files cannot be read over plain URLs`);
478
431
  }
479
- // The index answers existence + write time + size without touching any data, so
480
- // If-Modified-Since and range validation cost nothing
481
432
  let info = await bucket.store.getInfo(filePath);
482
433
  if (!info || !info.size) {
483
434
  return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
@@ -494,7 +445,6 @@ class RemoteStorageControllerBase {
494
445
  let ifModifiedSince = request?.headers["if-modified-since"];
495
446
  if (typeof ifModifiedSince === "string") {
496
447
  let since = new Date(ifModifiedSince).getTime();
497
- // Last-Modified is served at 1 second resolution, so compare at that resolution
498
448
  if (since && Math.floor(info.writeTime / 1000) * 1000 <= since) {
499
449
  return setHTTPResultHeaders(Buffer.from(""), { ...headers, status: "304" });
500
450
  }
@@ -502,7 +452,6 @@ class RemoteStorageControllerBase {
502
452
  let range: { start: number; end: number } | undefined;
503
453
  let rangeHeader = request?.headers["range"];
504
454
  if (typeof rangeHeader === "string") {
505
- // Single-range form only (bytes=start-end / start- / -suffix); anything else serves the full file
506
455
  let match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
507
456
  if (match && (match[1] || match[2])) {
508
457
  let start: number;
@@ -534,14 +483,16 @@ class RemoteStorageControllerBase {
534
483
 
535
484
  const largeUploadInfo = new Map<string, { account: string; bucketName: string; path: string }>();
536
485
 
537
- // Access checks run as hooks on the register shape below, keyed off the call's arguments, so the
538
- // method bodies don't each repeat them
539
486
  const accountAccess: SocketFunctionHook = async (context) => {
487
+ let start = Date.now();
540
488
  await requireAccess(String(context.call.args[0]));
489
+ let duration = Date.now() - start;
490
+ if (duration > ACCESS_CHECK_SLOW_TIME) {
491
+ console.log(`Access check for ${context.call.functionName} took ${duration}ms`);
492
+ }
541
493
  };
542
494
  const uploadAccess: SocketFunctionHook = async (context) => {
543
495
  let info = largeUploadInfo.get(String(context.call.args[0]));
544
- // Unknown upload ids are handled by the methods themselves (throw / no-op)
545
496
  if (!info) return;
546
497
  await requireAccess(info.account);
547
498
  };
@@ -571,6 +522,9 @@ export const RemoteStorageController = SocketFunction.register(
571
522
  getArchivesConfig: { hooks: [accountAccess] },
572
523
  getIndexInfo: { hooks: [accountAccess] },
573
524
  listBuckets: { hooks: [accountAccess] },
525
+ getActiveBucket: { hooks: [accountAccess] },
526
+ activateBucket: { hooks: [accountAccess] },
527
+ clearWriteStats: { hooks: [accountAccess] },
574
528
  getSyncStatus: { hooks: [accountAccess] },
575
529
  startLargeFile: { hooks: [accountAccess] },
576
530
  uploadPart: { hooks: [uploadAccess] },
@@ -6,34 +6,25 @@ 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
8
  import { RemoteStorageController } from "./storageController";
9
- import { setStorageServerConfig, setWritesRejectedReason, addExtraListenPort } from "./storageServerState";
10
- import { initDeployTakeover, registerAltPort, getMainPortAcquireDelay } from "./deployTakeover";
9
+ import { setStorageServerConfig, setWritesRejectedReason, addExtraListenPort, removeExtraListenPort } from "./storageServerState";
10
+ import { detectDeployTakeover, setAltPort, getAltPortListenEnd, getMainPortAcquireDelay } from "./deployTakeover";
11
11
  import { parseStorageUrl } from "./ArchivesRemote";
12
12
  // Import browser code, so it is allowed to be required by the client
13
13
  import "./accessPage";
14
14
 
15
15
  const DEFAULT_LOW_SPACE_THRESHOLD_BYTES = 25 * 1024 ** 3;
16
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.
17
+ // Below this fraction of the warn threshold, we start rejecting writes so the server itself doesn't tip the machine into instability. Reads/deletes still work so users can free space.
19
18
  const HARD_REJECT_FRACTION = 0.1;
20
19
 
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.
20
+ // The remote storage server, as a library function: consumers call hostStorageServer() from their own process to start hosting (or use the storageserver bin, see storageServerCli.ts). The grantAccess.js bootstrap (next to this file) is what the access page's shown SSH command points at.
24
21
 
25
22
  export type HostStorageServerConfig = {
26
- // Full URL of this storage server, e.g. "https://1-2-3-4.example.com:4444". The subdomain must
27
- // be an ip domain - this machine's external IP with dashes, or 127-0-0-1 for local testing
28
- // (see the validation in hostStorageServer). The domain and port are extracted from it (bucket
29
- // routing URLs clients use look like
23
+ // Full URL of this storage server, e.g. "https://1-2-3-4.example.com:4444". The subdomain must be an ip domain - this machine's external IP with dashes, or 127-0-0-1 for local testing (see the validation in hostStorageServer). The domain and port are extracted from it (bucket routing URLs clients use look like
30
24
  // https://1-2-3-4.example.com:4444/file/<account>/<bucketName>/storage/storagerouting.json).
31
25
  url: string;
32
26
  folder: string;
33
- // When free space on the folder's drive drops below this many bytes, the server console.errors
34
- // every 15 minutes. Below 10% of it, the server also rejects write operations (creating files,
35
- // large uploads, new buckets) — reads, findInfo, and deletes still work so the user can free
36
- // space. Default 25 GiB.
27
+ // When free space on the folder's drive drops below this many bytes, the server console.errors every 15 minutes. Below 10% of it, the server also rejects write operations (creating files, large uploads, new buckets) — reads, findInfo, and deletes still work so the user can free space. Default 25 GiB.
37
28
  lowSpaceThresholdBytes?: number;
38
29
  };
39
30
 
@@ -68,9 +59,7 @@ async function checkDiskSpace(config: { folder: string; threshold: number }): Pr
68
59
  }
69
60
  }
70
61
 
71
- // Full path to the grantAccess CLI bootstrap that lives next to this file. The SSH command shown on
72
- // the access page invokes it via `node <path> ...` (found through __dirname so consumers don't have
73
- // to know where our source lives).
62
+ // Full path to the grantAccess CLI bootstrap that lives next to this file. The SSH command shown on the access page invokes it via `node <path> ...` (found through __dirname so consumers don't have to know where our source lives).
74
63
  function getGrantAccessCliPath(): string {
75
64
  return path.join(__dirname, "grantAccess.js");
76
65
  }
@@ -80,9 +69,7 @@ export async function hostStorageServer(config: HostStorageServerConfig): Promis
80
69
  let { address: domain, port } = parseStorageUrl(url);
81
70
  let rootDomain = domain.split(".").slice(-2).join(".");
82
71
  let externalIP = (await getExternalIP()).trim();
83
- // The subdomain must be an ip domain: the domain's A record points at exactly one machine, so a
84
- // dynamic name would let the same script run on two servers and silently fight over it. Encoding
85
- // the IP makes that mistake fail loudly - the wrong machine's domain just doesn't match.
72
+ // The subdomain must be an ip domain: the domain's A record points at exactly one machine, so a dynamic name would let the same script run on two servers and silently fight over it. Encoding the IP makes that mistake fail loudly - the wrong machine's domain just doesn't match.
86
73
  let allowedDomains = [`127-0-0-1.${rootDomain}`, `${externalIP.replaceAll(".", "-")}.${rootDomain}`];
87
74
  if (!allowedDomains.includes(domain)) {
88
75
  throw new Error(`The storage server domain is based on the machine's IP (the subdomain is the IP with dots replaced by dashes). Expected ${allowedDomains.join(" or ")}, was ${domain}. Your external IP is ${externalIP}.`);
@@ -101,17 +88,12 @@ export async function hostStorageServer(config: HostStorageServerConfig): Promis
101
88
  RequireController.allowAllNodeModules();
102
89
  SocketFunction.expose(RequireController);
103
90
  SocketFunction.expose(RemoteStorageController);
104
- // Every HTTP path goes through httpEntry: /file/<account>/<bucketName>/... serves public
105
- // bucket files, everything else serves the access page (the path is the account name, see
106
- // accessPage.tsx).
107
- // A full URL, so the page resolves modules from the origin root even when served at
108
- // /accountName (a relative require would resolve inside the account path).
91
+ // Every HTTP path goes through httpEntry: /file/<account>/<bucketName>/... serves public bucket files, everything else serves the access page (the path is the account name, see accessPage.tsx). A full URL, so the page resolves modules from the origin root even when served at /accountName (a relative require would resolve inside the account path).
109
92
  SocketFunction.setDefaultHTTPCall(RemoteStorageController, "httpEntry", {
110
93
  requireCalls: [`https://${domain}:${port}/./storage/remoteStorage/accessPage.tsx`],
111
94
  });
112
95
 
113
- // Initial check so a server starting under-limit immediately rejects writes; then keep checking
114
- // every 15 minutes so recovery (freed disk space) is picked up automatically.
96
+ // Initial check so a server starting under-limit immediately rejects writes; then keep checking every 15 minutes so recovery (freed disk space) is picked up automatically.
115
97
  await checkDiskSpace({ folder, threshold: lowSpaceThreshold });
116
98
  let interval = setInterval(() => {
117
99
  void checkDiskSpace({ folder, threshold: lowSpaceThreshold })
@@ -119,21 +101,25 @@ export async function hostStorageServer(config: HostStorageServerConfig): Promis
119
101
  }, DISK_SPACE_CHECK_INTERVAL_MS);
120
102
  (interval as { unref?: () => void }).unref?.();
121
103
 
122
- // Port fallback is always on: a busy port means listening on an alternate, registering it, and
123
- // polling to take the real port. The deploy takeover machinery merely rides on top (tighter
124
- // polling around a scheduled switchover, and the valid-window remap once a successor is known).
125
- await initDeployTakeover({ domain, mainPort: port, storageFolder: path.resolve(folder) });
126
-
127
104
  await hostServer({
128
105
  domain,
129
106
  port,
130
107
  setDNSRecord: true,
131
108
  portFallback: {
132
109
  getAcquireDelay: getMainPortAcquireDelay,
110
+ onPortInUse: async () => {
111
+ await detectDeployTakeover();
112
+ },
133
113
  onListening: (listeningPort, isMainPort) => {
134
114
  if (isMainPort) return;
135
115
  addExtraListenPort(listeningPort);
136
- void registerAltPort(listeningPort).catch((e: Error) => console.error(`Registering alternate port ${listeningPort} failed: ${e.stack ?? e}`));
116
+ setAltPort(listeningPort);
117
+ let listenEnd = getAltPortListenEnd();
118
+ let timer = setTimeout(() => {
119
+ console.log(`Alternate port ${listeningPort} reached its end of life at ${new Date(listenEnd).toISOString()}; it no longer counts as one of our addresses (the socket itself stays bound, as the main-port relay forwards into it)`);
120
+ removeExtraListenPort(listeningPort);
121
+ }, Math.max(0, listenEnd - Date.now()));
122
+ (timer as { unref?: () => void }).unref?.();
137
123
  },
138
124
  },
139
125
  });
@@ -20,7 +20,16 @@ export declare function getWritesRejectedReason(): string | undefined;
20
20
  export declare function assertWritesAllowed(): void;
21
21
  export declare function getTrust(): Promise<IStorage<TrustRecord>>;
22
22
  export declare function getRequests(): Promise<IStorage<AccessRequest[]>>;
23
- /** 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. */
23
+ export type BucketWriteStats = {
24
+ /** Every set call the bucket accepted */
25
+ originalWrites: number;
26
+ originalBytes: number;
27
+ /** What actually reached the sources. Fast writes coalesce repeated writes to the same key, so this is lower than the original counts (and is what the disk actually did). */
28
+ flushedWrites: number;
29
+ flushedBytes: number;
30
+ };
31
+ /** Zeroes the write statistics of every bucket in the account, including counts not yet flushed. */
32
+ export declare function clearAccountWriteStats(account: string): Promise<number>;
24
33
  export declare function setTrustedMachines(config: {
25
34
  account: string;
26
35
  machineIds: string[];
@@ -36,6 +45,7 @@ export type LoadedBucket = {
36
45
  structureKey: string;
37
46
  };
38
47
  export declare function addExtraListenPort(port: number): void;
48
+ export declare function removeExtraListenPort(port: number): void;
39
49
  export declare function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined>;
40
50
  export declare function assertMutable(bucket: LoadedBucket, filePath: string, writeTime: number): Promise<void>;
41
51
  export declare function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: {
@@ -43,16 +53,42 @@ export declare function writeBucketFile(account: string, bucketName: string, fil
43
53
  }): Promise<void>;
44
54
  export declare function getBucketConfig(bucket: LoadedBucket): ArchivesConfig;
45
55
  export declare function rebuildAllLoadedBuckets(): Promise<void>;
56
+ /** Started by deployTakeover once we are actually a deploy successor listening on an alternate port. Until then there are no switchover windows to write or expire, so nothing polls. */
57
+ export declare const startIntermediateMaintenance: {
58
+ (): void;
59
+ reset(): void;
60
+ set(newValue: void): void;
61
+ };
62
+ export type BucketDiskInfo = {
63
+ totalBytes: number;
64
+ freeBytes: number;
65
+ usedBytes: number;
66
+ };
46
67
  export type ServerBucketInfo = {
47
68
  bucketName: string;
48
69
  active: boolean;
70
+ /** Where the bucket's data lives on this server */
71
+ folder: string;
72
+ /** The drive that folder is on. Buckets sharing a drive report the same numbers. */
73
+ disk?: BucketDiskInfo;
74
+ diskError?: string;
75
+ writeStats?: BucketWriteStats;
49
76
  config?: ArchivesConfig;
50
77
  error?: string;
51
78
  };
52
- /** Every bucket the account has on this server, active or not, each with its configuration.
53
- * Inactive buckets are inspected straight from disk WITHOUT loading them - loading would start
54
- * their synchronization, and old invalid buckets must stay inert (their parse error is reported
55
- * instead). */
79
+ export type ActiveBucketInfo = {
80
+ folder: string;
81
+ /** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded */
82
+ routing: RemoteConfig;
83
+ /** Our own entries in that config, and the one currently valid */
84
+ selfEntries: HostedConfig[];
85
+ self?: HostedConfig;
86
+ config: ArchivesConfig;
87
+ };
88
+ /** The live in-memory state of ONE bucket, answered without touching the disk (no routing file read, no statfs, no stored write stats). Returns an error string when the bucket is not loaded here, which is the normal state for a bucket nothing has accessed since startup. */
89
+ export declare function getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
90
+ /** Loads a bucket that exists on this server's disk into memory, which starts its synchronization and window timers, and returns its live state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-loaded buckets just return their state. */
91
+ export declare function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
56
92
  export declare function listAccountBuckets(account: string): Promise<ServerBucketInfo[]>;
57
93
  export declare function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void>;
58
94
  export declare function getLocalArchives(account: string, bucketName: string): IArchives;