ankka 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +594 -0
  2. package/dist/_proto/ankka/protocol/v1/client_pb.js +172 -30
  3. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  4. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +134 -0
  5. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
  6. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +24 -0
  7. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +1 -1
  8. package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
  9. package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
  10. package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +1 -1
  11. package/dist/_proto/ankka/protocol/v1/view_pb.js +1 -1
  12. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  13. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  14. package/dist/agent.d.ts +10 -0
  15. package/dist/agent.js +20 -0
  16. package/dist/autonomous.d.ts +5 -0
  17. package/dist/autonomous.js +10 -0
  18. package/dist/client-keys.d.ts +89 -0
  19. package/dist/client-keys.js +182 -0
  20. package/dist/consumer.d.ts +5 -0
  21. package/dist/consumer.js +10 -0
  22. package/dist/context.d.ts +11 -3
  23. package/dist/context.js +20 -4
  24. package/dist/endpoint.d.ts +10 -0
  25. package/dist/endpoint.js +20 -0
  26. package/dist/graph.d.ts +5 -0
  27. package/dist/graph.js +10 -0
  28. package/dist/index.d.ts +6 -1
  29. package/dist/index.js +5 -0
  30. package/dist/mcp-endpoint.d.ts +144 -0
  31. package/dist/mcp-endpoint.js +256 -0
  32. package/dist/routes.d.ts +25 -2
  33. package/dist/routes.js +29 -2
  34. package/dist/server/discovery.d.ts +7 -0
  35. package/dist/server/discovery.js +26 -2
  36. package/dist/server/eventSourced.js +3 -3
  37. package/dist/server/http.d.ts +8 -0
  38. package/dist/server/http.js +10 -4
  39. package/dist/server/mcp.d.ts +17 -0
  40. package/dist/server/mcp.js +157 -0
  41. package/dist/server/server.d.ts +11 -1
  42. package/dist/server/server.js +19 -0
  43. package/dist/server/storageEndpoint.d.ts +14 -0
  44. package/dist/server/storageEndpoint.js +82 -0
  45. package/dist/service.d.ts +6 -1
  46. package/dist/service.js +47 -4
  47. package/dist/sigv4.d.ts +47 -0
  48. package/dist/sigv4.js +149 -0
  49. package/dist/spec.d.ts +8 -2
  50. package/dist/spec.js +53 -3
  51. package/dist/storage.d.ts +288 -0
  52. package/dist/storage.js +1081 -0
  53. package/dist/testkit/integration.d.ts +9 -0
  54. package/dist/testkit/integration.js +5 -1
  55. package/dist/testkit/kinds.d.ts +16 -4
  56. package/dist/testkit/kinds.js +36 -14
  57. package/dist/testkit/unit.d.ts +4 -1
  58. package/dist/testkit/unit.js +8 -4
  59. package/dist/timedAction.d.ts +10 -0
  60. package/dist/timedAction.js +20 -0
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/dist/workflow.d.ts +8 -0
  64. package/dist/workflow.js +15 -0
  65. package/package.json +2 -2
@@ -0,0 +1,1081 @@
1
+ // A service's buckets, reached by name (feature 055): `this.storage.default` is the bucket
2
+ // `provisionObjectStorage` asks for, and `this.storage.bucket("photos")` one the descriptor's `buckets`
3
+ // list names. Offered where a wait belongs, as the secret store is — endpoints, workflow steps,
4
+ // consumers, graph consumers, timed actions, agents and autonomous agents — and never an entity or a
5
+ // view. An entity keeps an object's *name*; the bucket keeps the bytes.
6
+ //
7
+ // A process speaks S3 itself (research R14): nothing of an object crosses the sidecar. The stores are
8
+ // the runtime's, said again in TypeScript with the same rules and the same words:
9
+ //
10
+ // - `S3Buckets`, from the `ANKKA_S3_*` variables the platform renders or a service supplies, over
11
+ // `node:http`/`node:https` with `Content-Length` set and the body piped (never `fetch`, which sends
12
+ // a stream chunked, and S3 refuses a chunked put);
13
+ // - `DirectoryBuckets`, a directory on a developer's machine, in the runtime's layout, whose signed
14
+ // URLs the service serves itself at `/_ankka/storage` through its sidecar;
15
+ // - `InMemoryBuckets`, the test kits' store.
16
+ //
17
+ // Every call refuses with `CommandError`: `BAD_REQUEST` for a name, a prefix, a length or a span that
18
+ // breaks a rule, and for a signed URL or an address the bucket's exposure does not allow; `UNAVAILABLE`
19
+ // when the store cannot be reached, when the service has no such bucket, or when it has no storage
20
+ // credential; `INTERNAL` for an answer the client cannot read.
21
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
22
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { mkdir, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
24
+ import { Agent as HttpAgent, request as httpRequest } from "node:http";
25
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
26
+ import { isAbsolute, join, resolve } from "node:path";
27
+ import { Readable } from "node:stream";
28
+ import { CommandError, ErrorCode } from "./effects/common.js";
29
+ import { Credentials, EMPTY_PAYLOAD, UNSIGNED_PAYLOAD, encode, hmac, presign, sha256Hex, sign, SignedRequest } from "./sigv4.js";
30
+ import { Instant } from "./time.js";
31
+ // ── The rules and the words ────────────────────────────────────────────────────
32
+ const utf8 = new TextEncoder();
33
+ function shown(text) {
34
+ let visible = "";
35
+ for (const c of text)
36
+ visible += c < " " || c === "\u007f" ? "?" : c;
37
+ // As the runtime counts: UTF-16 units.
38
+ return visible.length > 60 ? visible.slice(0, 60) + "…" : visible;
39
+ }
40
+ function problem(subject, text, trailingSlash) {
41
+ const bytes = utf8.encode(text).length;
42
+ const body = trailingSlash && text.endsWith("/") ? text.slice(0, -1) : text;
43
+ const segments = body.split("/");
44
+ let found;
45
+ if (text === "")
46
+ found = `${subject} must not be empty`;
47
+ else if (bytes > ObjectRules.MAX_NAME_BYTES)
48
+ found = `${subject} is at most ${ObjectRules.MAX_NAME_BYTES} bytes as UTF-8; this one is ${bytes}`;
49
+ else if (text.startsWith("/"))
50
+ found = `${subject} must not begin with '/'`;
51
+ else if ([...text].some((c) => c < " " || c === "\u007f"))
52
+ found = `${subject} must not hold a control character`;
53
+ else if (segments.some((s) => s === ""))
54
+ found = `${subject} must not have an empty segment`;
55
+ else if (segments.some((s) => s === "." || s === ".."))
56
+ found = `${subject} must not have a segment '.' or '..'`;
57
+ return found === undefined ? undefined : `${found} ("${shown(text)}")`;
58
+ }
59
+ function badRequest(message) {
60
+ return new CommandError({ message, code: ErrorCode.BadRequest });
61
+ }
62
+ /**
63
+ * What an object's name, a prefix, a bucket's name and a signed URL's span may be. Every store and every
64
+ * test double applies these, and `protocol/fixtures/storage/rules.json` holds them to the other SDKs.
65
+ */
66
+ export const ObjectRules = Object.freeze({
67
+ MAX_NAME_BYTES: 1024,
68
+ MAX_BUCKET_NAME: 32,
69
+ /** Seven days. */
70
+ MAX_SIGNED_URL_SECONDS: 604_800,
71
+ /** Fifteen minutes. */
72
+ DEFAULT_SIGNED_URL_SECONDS: 900,
73
+ BUCKET_NAME_RULE: "lower-case letters, digits and '-', 1 to 32 characters",
74
+ /** What is wrong with an object's `name`, if anything. */
75
+ nameProblem(name) {
76
+ return problem("an object's name", name, false);
77
+ },
78
+ /** What is wrong with a listing's `prefix`, if anything. Empty is everything. */
79
+ prefixProblem(prefix) {
80
+ return prefix === "" ? undefined : problem("a prefix", prefix, true);
81
+ },
82
+ /** What is wrong with a bucket's name, if anything, in the descriptor's words. */
83
+ bucketNameProblem(name) {
84
+ return /^[a-z0-9-]{1,32}$/.test(name) ? undefined : `'${shown(name)}' is not a bucket name: ${ObjectRules.BUCKET_NAME_RULE}`;
85
+ },
86
+ /** What is wrong with a signed URL's span, in seconds, if anything. */
87
+ spanProblem(spanSeconds) {
88
+ if (!Number.isFinite(spanSeconds) || spanSeconds <= 0)
89
+ return `a signed URL's span is positive; this one is ${Math.trunc(spanSeconds)} seconds`;
90
+ if (spanSeconds > ObjectRules.MAX_SIGNED_URL_SECONDS) {
91
+ return `a signed URL is made for at most 7 days (${ObjectRules.MAX_SIGNED_URL_SECONDS} seconds); this one asks for ${Math.trunc(spanSeconds)}`;
92
+ }
93
+ return undefined;
94
+ },
95
+ checkName(name) {
96
+ const p = ObjectRules.nameProblem(name);
97
+ if (p !== undefined)
98
+ throw badRequest(p);
99
+ },
100
+ checkPrefix(prefix) {
101
+ const p = ObjectRules.prefixProblem(prefix);
102
+ if (p !== undefined)
103
+ throw badRequest(p);
104
+ },
105
+ checkSpan(spanSeconds) {
106
+ const p = ObjectRules.spanProblem(spanSeconds);
107
+ if (p !== undefined)
108
+ throw badRequest(p);
109
+ },
110
+ });
111
+ /** What the storage client says when it refuses: the runtime's words (`StorageRefusals` in the Scala SDK). */
112
+ export const StorageRefusals = Object.freeze({
113
+ noBucket() {
114
+ return new CommandError({
115
+ message: "this service has no bucket: it asks for one with provisionObjectStorage, or names one in buckets",
116
+ code: ErrorCode.Unavailable,
117
+ });
118
+ },
119
+ unknown(name, known) {
120
+ const names = [...known].sort();
121
+ const named = names.length === 0 ? "names none" : "names " + names.map((n) => `'${n}'`).join(", ");
122
+ return new CommandError({ message: `this service has no bucket named '${name}'; its descriptor ${named}`, code: ErrorCode.Unavailable });
123
+ },
124
+ noCredential() {
125
+ return new CommandError({
126
+ message: "this service has a bucket and no storage credential to sign with: it sets objectStorageCredential to false, " +
127
+ "so it reaches its bucket as its workload identity, with its store's own client",
128
+ code: ErrorCode.Unavailable,
129
+ });
130
+ },
131
+ notExposed() {
132
+ return badRequest("the service's bucket is not reachable from the internet, so it signs no URL: the descriptor sets exposeObjectStorage, or names a signed bucket");
133
+ },
134
+ notSigned(bucket, exposure) {
135
+ return badRequest(`bucket '${bucket}' is ${exposure}; a signed URL is made for a signed bucket`);
136
+ },
137
+ notPublic(bucket, exposure) {
138
+ const label = bucket === undefined ? "the service's unnamed bucket" : `bucket '${bucket}'`;
139
+ return badRequest(`${label} is ${exposure}; an address with no signature is a public bucket's`);
140
+ },
141
+ unreachable(endpoint, cause) {
142
+ return new CommandError({ message: `the object store at ${endpoint} cannot be reached: ${cause}`, code: ErrorCode.Unavailable });
143
+ },
144
+ refusedByStore(endpoint, status, detail) {
145
+ return new CommandError({
146
+ message: `the object store at ${endpoint} refused the request (${status})${detail === "" ? "" : ": " + detail}`,
147
+ code: ErrorCode.Unavailable,
148
+ });
149
+ },
150
+ unreadable(endpoint, cause) {
151
+ return new CommandError({ message: `the object store at ${endpoint} answered with something unreadable: ${cause}`, code: ErrorCode.Internal });
152
+ },
153
+ shortBody(declared, read) {
154
+ return badRequest(`the stream ended after ${read} of its declared ${declared} bytes; nothing was kept`);
155
+ },
156
+ negativeLength(length) {
157
+ return badRequest(`an object's length is not negative; this one is ${length}`);
158
+ },
159
+ /** TypeScript's own: a stream's length cannot be known before it is read, and S3 is told it first. */
160
+ undeclaredLength() {
161
+ return badRequest("a put of a stream declares its length: put(name, stream, { contentType, length })");
162
+ },
163
+ stepsOnly() {
164
+ return badRequest("a workflow keeps and reads an object in a step, not in a command handler");
165
+ },
166
+ });
167
+ // ── Bodies ─────────────────────────────────────────────────────────────────────
168
+ /** The length a put keeps: a stream's declared one, or the bytes' own. */
169
+ function declaredLength(body, options) {
170
+ const length = options.length ?? (body instanceof Uint8Array ? body.length : undefined);
171
+ if (length === undefined)
172
+ throw StorageRefusals.undeclaredLength();
173
+ if (!Number.isInteger(length) || length < 0)
174
+ throw StorageRefusals.negativeLength(length);
175
+ return length;
176
+ }
177
+ /**
178
+ * At most `length` bytes of `body`, chunk by chunk; the caller learns how many it was given, and a body
179
+ * that ends early is short. Never reads past `length`.
180
+ */
181
+ async function* bounded(body, length) {
182
+ if (body instanceof Uint8Array) {
183
+ if (length > 0)
184
+ yield body.subarray(0, Math.min(length, body.length));
185
+ return;
186
+ }
187
+ const reader = body.getReader();
188
+ let read = 0;
189
+ try {
190
+ while (read < length) {
191
+ const next = await reader.read();
192
+ if (next.done)
193
+ return;
194
+ const chunk = next.value.length > length - read ? next.value.subarray(0, length - read) : next.value;
195
+ read += chunk.length;
196
+ if (chunk.length > 0)
197
+ yield chunk;
198
+ }
199
+ }
200
+ finally {
201
+ // The caller's stream is theirs: released, not cancelled, once its declared length is read.
202
+ reader.releaseLock();
203
+ }
204
+ }
205
+ async function collect(body, length) {
206
+ const parts = [];
207
+ let read = 0;
208
+ for await (const chunk of bounded(body, length)) {
209
+ parts.push(chunk);
210
+ read += chunk.length;
211
+ }
212
+ if (read < length)
213
+ throw StorageRefusals.shortBody(length, read);
214
+ const out = new Uint8Array(read);
215
+ let at = 0;
216
+ for (const p of parts) {
217
+ out.set(p, at);
218
+ at += p.length;
219
+ }
220
+ return out;
221
+ }
222
+ function streamOf(bytes) {
223
+ return new ReadableStream({
224
+ start(controller) {
225
+ if (bytes.length > 0)
226
+ controller.enqueue(new Uint8Array(bytes));
227
+ controller.close();
228
+ },
229
+ });
230
+ }
231
+ async function readAll(body) {
232
+ const parts = [];
233
+ let total = 0;
234
+ for await (const chunk of body) {
235
+ parts.push(chunk);
236
+ total += chunk.length;
237
+ }
238
+ const out = new Uint8Array(total);
239
+ let at = 0;
240
+ for (const p of parts) {
241
+ out.set(p, at);
242
+ at += p.length;
243
+ }
244
+ return out;
245
+ }
246
+ function storedObject(contentType, length, lastKept, body) {
247
+ return Object.freeze({ contentType, length, lastKept, body, bytes: () => readAll(body) });
248
+ }
249
+ function nowSeconds() {
250
+ return Math.floor(Date.now() / 1000);
251
+ }
252
+ function checkSigned(bucket, exposure) {
253
+ if (exposure === "signed" || exposure === "supplied")
254
+ return;
255
+ throw bucket === undefined ? StorageRefusals.notExposed() : StorageRefusals.notSigned(bucket, exposure);
256
+ }
257
+ // ── No store at all ────────────────────────────────────────────────────────────
258
+ /** Buckets that refuse every call, naming `provisionObjectStorage`: a deployed service with no bucket. */
259
+ export const noStorage = Object.freeze({
260
+ get default() {
261
+ throw StorageRefusals.noBucket();
262
+ },
263
+ bucket(name) {
264
+ throw StorageRefusals.unknown(name, []);
265
+ },
266
+ get buckets() {
267
+ return [];
268
+ },
269
+ });
270
+ /**
271
+ * A service's buckets in memory: the test kits' store, so a test of a handler that keeps or reads an
272
+ * object needs no object store and no Docker. It applies every rule the other stores apply, in the
273
+ * same words. A signed URL is computed (`http://memory/...`) and reaches nothing.
274
+ */
275
+ export class InMemoryBuckets {
276
+ #exposures = new Map();
277
+ #objects = new Map();
278
+ #unnamed;
279
+ #unnamedExposure;
280
+ #publicBase;
281
+ constructor(options = {}) {
282
+ this.#unnamed = options.unnamed ?? true;
283
+ this.#unnamedExposure = options.unnamedExposure ?? "signed";
284
+ this.#publicBase = options.publicBase ?? ((bucket) => `http://memory/public/${bucket}`);
285
+ for (const [name, exposure] of Object.entries(options.buckets ?? {}))
286
+ this.declare(name, exposure);
287
+ }
288
+ get default() {
289
+ if (!this.#unnamed)
290
+ throw StorageRefusals.noBucket();
291
+ return this.#bucket(undefined, this.#unnamedExposure);
292
+ }
293
+ bucket(name) {
294
+ const exposure = this.#exposures.get(name);
295
+ if (exposure === undefined)
296
+ throw StorageRefusals.unknown(name, this.#exposures.keys());
297
+ return this.#bucket(name, exposure);
298
+ }
299
+ get buckets() {
300
+ return [...this.#exposures.entries()].map(([n, e]) => this.#bucket(n, e));
301
+ }
302
+ /** Declares a named bucket, before or after the service starts. */
303
+ declare(name, exposure) {
304
+ const p = ObjectRules.bucketNameProblem(name);
305
+ if (p !== undefined)
306
+ throw new TypeError(p);
307
+ this.#exposures.set(name, exposure);
308
+ }
309
+ /** Forgets every object; the declared buckets stay. */
310
+ clear() {
311
+ this.#objects.clear();
312
+ }
313
+ #bucket(name, exposure) {
314
+ const key = name ?? "";
315
+ const objects = () => {
316
+ let m = this.#objects.get(key);
317
+ if (!m) {
318
+ m = new Map();
319
+ this.#objects.set(key, m);
320
+ }
321
+ return m;
322
+ };
323
+ const publicBase = this.#publicBase;
324
+ return Object.freeze({
325
+ name,
326
+ exposure,
327
+ async put(objectName, body, options) {
328
+ ObjectRules.checkName(objectName);
329
+ const length = declaredLength(body, options);
330
+ const bytes = await collect(body, length);
331
+ objects().set(objectName, { contentType: options.contentType, lastKept: Instant.now(), bytes: new Uint8Array(bytes) });
332
+ },
333
+ async get(objectName) {
334
+ ObjectRules.checkName(objectName);
335
+ const s = objects().get(objectName);
336
+ return s && storedObject(s.contentType, s.bytes.length, s.lastKept, streamOf(s.bytes));
337
+ },
338
+ async delete(objectName) {
339
+ ObjectRules.checkName(objectName);
340
+ objects().delete(objectName);
341
+ },
342
+ list(prefix) {
343
+ ObjectRules.checkPrefix(prefix);
344
+ const entries = [...objects().entries()]
345
+ .filter(([n]) => n.startsWith(prefix))
346
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
347
+ .map(([n, s]) => Object.freeze({ name: n, length: s.bytes.length, lastKept: s.lastKept }));
348
+ return (async function* () {
349
+ yield* entries;
350
+ })();
351
+ },
352
+ async stat(objectName) {
353
+ ObjectRules.checkName(objectName);
354
+ const s = objects().get(objectName);
355
+ return s && Object.freeze({ name: objectName, contentType: s.contentType, length: s.bytes.length, lastKept: s.lastKept });
356
+ },
357
+ signedUrl(objectName, access, spanSeconds = ObjectRules.DEFAULT_SIGNED_URL_SECONDS) {
358
+ ObjectRules.checkName(objectName);
359
+ checkSigned(name, exposure);
360
+ ObjectRules.checkSpan(spanSeconds);
361
+ const expires = nowSeconds() + Math.trunc(spanSeconds);
362
+ return `http://memory/${name ?? "default"}/${encode(objectName, false)}?access=${access}&expires=${expires}`;
363
+ },
364
+ url(objectName) {
365
+ ObjectRules.checkName(objectName);
366
+ if (name === undefined || exposure !== "public")
367
+ throw StorageRefusals.notPublic(name, exposure);
368
+ return publicBase(name).replace(/\/$/, "") + "/" + encode(objectName, false);
369
+ },
370
+ });
371
+ }
372
+ }
373
+ /**
374
+ * The buckets `service.json` in `directory` declares, read where a service runs on a developer's machine
375
+ * and in a test, so the directory and memory stores refuse the names a deployed service would; `undefined`
376
+ * when there is no such file. Only the two fields that say which buckets there are.
377
+ */
378
+ export function declaredBuckets(directory = process.cwd()) {
379
+ const file = join(directory, "service.json");
380
+ if (!existsSync(file))
381
+ return undefined;
382
+ let parsed;
383
+ try {
384
+ parsed = JSON.parse(readFileSync(file, "utf8"));
385
+ }
386
+ catch (e) {
387
+ throw new Error(`${file} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
388
+ }
389
+ const service = parsed?.service ?? {};
390
+ const buckets = {};
391
+ for (const b of Array.isArray(service.buckets) ? service.buckets : []) {
392
+ const name = String(b?.name ?? "");
393
+ if (b?.supplied !== undefined && b.supplied !== null) {
394
+ buckets[name] = "supplied";
395
+ continue;
396
+ }
397
+ const word = typeof b?.exposure === "string" ? b.exposure : "private";
398
+ if (word !== "private" && word !== "signed" && word !== "public") {
399
+ throw new Error(`buckets: '${name}' has exposure '${word}'; it is one of private, signed or public`);
400
+ }
401
+ buckets[name] = word;
402
+ }
403
+ return Object.freeze({ unnamed: service.provisionObjectStorage === true, buckets: Object.freeze(buckets) });
404
+ }
405
+ // ── A directory ────────────────────────────────────────────────────────────────
406
+ /** Where a service serves its directory store's objects. */
407
+ export const STORAGE_PATH = "/_ankka/storage";
408
+ /** The unnamed bucket's directory and URL segment: no bucket name begins with `_`. */
409
+ export const DEFAULT_SEGMENT = "_default";
410
+ /** The address a local service's HTTP port answers at, as the sidecar beside it binds it. */
411
+ export function localAddress(env = process.env) {
412
+ const host = env.ANKKA_HTTP_INTERFACE && env.ANKKA_HTTP_INTERFACE !== "0.0.0.0" ? env.ANKKA_HTTP_INTERFACE : "127.0.0.1";
413
+ return `http://${host}:${env.ANKKA_HTTP_PORT || "9000"}`;
414
+ }
415
+ /**
416
+ * A service's buckets in a directory on a developer's machine: what a service keeps when it runs with no
417
+ * variable of an object store. The runtime's layout exactly, so a Scala service and a TypeScript one read
418
+ * each other's: one directory per bucket under `root` (`_default` for the unnamed one), an object's name
419
+ * encoded into one file name under `objects/` (its hash when too long), and beside it under `meta/` its
420
+ * name, content type, length and when it was kept. A put writes a temporary file and moves it into place,
421
+ * so a stream that ends short leaves the old object.
422
+ *
423
+ * A signed URL and a public address point at the service's own HTTP port, under `/_ankka/storage`, which
424
+ * the SDK serves through the sidecar while this is the service's store.
425
+ */
426
+ export class DirectoryBuckets {
427
+ root;
428
+ #declared;
429
+ #addressBase;
430
+ #key;
431
+ constructor(root, options = {}) {
432
+ this.root = resolve(root);
433
+ this.#declared = options.declared;
434
+ this.#addressBase = options.addressBase ?? (() => localAddress());
435
+ }
436
+ get default() {
437
+ if (this.#declared && !this.#declared.unnamed)
438
+ throw StorageRefusals.noBucket();
439
+ return this.#bucket(undefined, "signed");
440
+ }
441
+ bucket(name) {
442
+ if (this.#declared) {
443
+ const exposure = this.#declared.buckets[name];
444
+ if (exposure === undefined)
445
+ throw StorageRefusals.unknown(name, Object.keys(this.#declared.buckets));
446
+ return this.#bucket(name, exposure);
447
+ }
448
+ const p = ObjectRules.bucketNameProblem(name);
449
+ if (p !== undefined)
450
+ throw badRequest(p);
451
+ return this.#bucket(name, "private");
452
+ }
453
+ get buckets() {
454
+ return Object.entries(this.#declared?.buckets ?? {}).map(([n, e]) => this.#bucket(n, e));
455
+ }
456
+ /** The bucket a URL's segment names, `_default` or a bucket's name, as this store would answer it. */
457
+ bySegment(segment) {
458
+ try {
459
+ return { bucket: segment === DEFAULT_SEGMENT ? this.default : this.bucket(segment), segment };
460
+ }
461
+ catch {
462
+ return undefined;
463
+ }
464
+ }
465
+ /** `HMAC-SHA256(key, "<segment>/<name>|<access>|<expires>")` in hex, over the key kept in the root. */
466
+ token(segment, objectName, access, expires) {
467
+ return hmac(this.#signingKey(), `${segment}/${objectName}|${access}|${expires}`).toString("hex");
468
+ }
469
+ /** Whether `token` signs this access until `expires`, and `expires` is still to come. */
470
+ admits(segment, objectName, access, expires, token) {
471
+ if (!Number.isInteger(expires) || expires < nowSeconds())
472
+ return false;
473
+ const want = Buffer.from(this.token(segment, objectName, access, expires), "utf8");
474
+ const got = Buffer.from(token, "utf8");
475
+ return want.length === got.length && timingSafeEqual(want, got);
476
+ }
477
+ /** The key the store's URLs are signed with, made once and kept in the root so a restart honours them. */
478
+ #signingKey() {
479
+ if (this.#key)
480
+ return this.#key;
481
+ const file = join(this.root, ".key");
482
+ try {
483
+ if (!existsSync(file)) {
484
+ mkdirSync(this.root, { recursive: true });
485
+ const part = join(this.root, `.key-${randomUUID()}`);
486
+ writeFileSync(part, randomBytes(32), { mode: 0o600 });
487
+ try {
488
+ chmodSync(part, 0o600);
489
+ }
490
+ catch {
491
+ // a file system without modes
492
+ }
493
+ if (existsSync(file))
494
+ rmSync(part, { force: true });
495
+ else
496
+ renameSync(part, file);
497
+ }
498
+ this.#key = readFileSync(file);
499
+ return this.#key;
500
+ }
501
+ catch (e) {
502
+ throw this.#failed(e);
503
+ }
504
+ }
505
+ #failed(e) {
506
+ return new CommandError({
507
+ message: `the directory store at ${this.root} cannot be used: ${e instanceof Error ? e.message : String(e)}`,
508
+ code: ErrorCode.Unavailable,
509
+ });
510
+ }
511
+ #bucket(name, exposure) {
512
+ const store = this;
513
+ const segment = name ?? DEFAULT_SEGMENT;
514
+ const objects = join(this.root, segment, "objects");
515
+ const metas = join(this.root, segment, "meta");
516
+ const file = (objectName) => {
517
+ const encoded = encode(objectName, true);
518
+ return encoded.length <= 200 ? encoded : "h-" + sha256Hex(objectName);
519
+ };
520
+ const io = async (body) => {
521
+ try {
522
+ return await body();
523
+ }
524
+ catch (e) {
525
+ if (e instanceof CommandError)
526
+ throw e;
527
+ throw store.#failed(e);
528
+ }
529
+ };
530
+ const meta = async (objectName) => {
531
+ try {
532
+ return JSON.parse(await readFile(join(metas, file(objectName) + ".json"), "utf8"));
533
+ }
534
+ catch (e) {
535
+ if (e.code === "ENOENT")
536
+ return undefined;
537
+ throw store.#failed(e);
538
+ }
539
+ };
540
+ const address = (objectName) => `${store.#addressBase().replace(/\/$/, "")}${STORAGE_PATH}/${segment}/${addressOf(objectName)}`;
541
+ return Object.freeze({
542
+ name,
543
+ exposure,
544
+ async put(objectName, body, options) {
545
+ ObjectRules.checkName(objectName);
546
+ const length = declaredLength(body, options);
547
+ const scratch = join(store.root, ".tmp");
548
+ await io(async () => {
549
+ await mkdir(objects, { recursive: true });
550
+ await mkdir(metas, { recursive: true });
551
+ await mkdir(scratch, { recursive: true });
552
+ });
553
+ const part = join(scratch, `${randomUUID()}.part`);
554
+ try {
555
+ const written = await io(async () => {
556
+ const handle = await open(part, "w");
557
+ let n = 0;
558
+ try {
559
+ for await (const chunk of bounded(body, length)) {
560
+ await handle.write(chunk);
561
+ n += chunk.length;
562
+ }
563
+ }
564
+ finally {
565
+ await handle.close();
566
+ }
567
+ return n;
568
+ });
569
+ if (written < length)
570
+ throw StorageRefusals.shortBody(length, written);
571
+ const kept = { name: objectName, contentType: options.contentType, length, lastKept: Instant.now().toString() };
572
+ const metaPart = join(scratch, `${randomUUID()}.meta`);
573
+ await io(async () => {
574
+ await writeFile(metaPart, JSON.stringify(kept));
575
+ await rename(part, join(objects, file(objectName)));
576
+ await rename(metaPart, join(metas, file(objectName) + ".json"));
577
+ });
578
+ }
579
+ finally {
580
+ await rm(part, { force: true });
581
+ }
582
+ },
583
+ async get(objectName) {
584
+ ObjectRules.checkName(objectName);
585
+ const m = await meta(objectName);
586
+ if (!m)
587
+ return undefined;
588
+ let handle;
589
+ try {
590
+ handle = await open(join(objects, file(objectName)), "r");
591
+ }
592
+ catch (e) {
593
+ if (e.code === "ENOENT")
594
+ return undefined;
595
+ throw store.#failed(e);
596
+ }
597
+ const body = Readable.toWeb(handle.createReadStream());
598
+ return storedObject(m.contentType, m.length, Instant.parse(m.lastKept), body);
599
+ },
600
+ async delete(objectName) {
601
+ ObjectRules.checkName(objectName);
602
+ await io(async () => {
603
+ await rm(join(metas, file(objectName) + ".json"), { force: true });
604
+ await rm(join(objects, file(objectName)), { force: true });
605
+ });
606
+ },
607
+ list(prefix) {
608
+ ObjectRules.checkPrefix(prefix);
609
+ return (async function* () {
610
+ let found;
611
+ try {
612
+ found = await readdir(metas);
613
+ }
614
+ catch (e) {
615
+ if (e.code === "ENOENT")
616
+ return;
617
+ throw store.#failed(e);
618
+ }
619
+ const entries = [];
620
+ for (const f of found) {
621
+ try {
622
+ const m = JSON.parse(await readFile(join(metas, f), "utf8"));
623
+ if (typeof m.name === "string" && m.name.startsWith(prefix))
624
+ entries.push(Object.freeze({ name: m.name, length: m.length, lastKept: Instant.parse(m.lastKept) }));
625
+ }
626
+ catch {
627
+ // a meta file being written, or not one: skipped, as the runtime skips it
628
+ }
629
+ }
630
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
631
+ yield* entries;
632
+ })();
633
+ },
634
+ async stat(objectName) {
635
+ ObjectRules.checkName(objectName);
636
+ const m = await meta(objectName);
637
+ return m && Object.freeze({ name: m.name, contentType: m.contentType, length: m.length, lastKept: Instant.parse(m.lastKept) });
638
+ },
639
+ signedUrl(objectName, access, spanSeconds = ObjectRules.DEFAULT_SIGNED_URL_SECONDS) {
640
+ ObjectRules.checkName(objectName);
641
+ checkSigned(name, exposure);
642
+ ObjectRules.checkSpan(spanSeconds);
643
+ const expires = nowSeconds() + Math.trunc(spanSeconds);
644
+ const token = store.token(segment, objectName, access, expires);
645
+ return `${address(objectName)}?access=${access}&expires=${expires}&token=${token}`;
646
+ },
647
+ url(objectName) {
648
+ ObjectRules.checkName(objectName);
649
+ if (exposure !== "public" || name === undefined)
650
+ throw StorageRefusals.notPublic(name, exposure);
651
+ return address(objectName);
652
+ },
653
+ });
654
+ }
655
+ }
656
+ /**
657
+ * The deepest object name whose address keeps its slashes. The sidecar matches a process's route by
658
+ * whole segments, so the storage endpoint declares one route per depth up to this; a deeper name's
659
+ * slashes are encoded and it travels as one segment.
660
+ */
661
+ export const MAX_ADDRESS_SEGMENTS = 16;
662
+ /** An object's name in a directory store's address: as the runtime writes it, while the sidecar can route it. */
663
+ export function addressOf(objectName) {
664
+ return encode(objectName, objectName.split("/").length > MAX_ADDRESS_SEGMENTS);
665
+ }
666
+ const PREFIX = "ANKKA_S3_";
667
+ function nonEmpty(env, name) {
668
+ const v = env[name];
669
+ return v === undefined || v === "" ? undefined : v;
670
+ }
671
+ function required(env, name) {
672
+ const v = nonEmpty(env, name);
673
+ if (v === undefined)
674
+ throw new Error(`${name} is not set`);
675
+ return v;
676
+ }
677
+ function parseUrl(name, value) {
678
+ let url;
679
+ try {
680
+ url = new URL(value);
681
+ }
682
+ catch {
683
+ throw new Error(`${name} is not a URL: '${value}'`);
684
+ }
685
+ if (url.protocol === "" || url.hostname === "")
686
+ throw new Error(`${name} is not a URL with a scheme and a host: '${value}'`);
687
+ return url;
688
+ }
689
+ /** The bucket a variable's `<NAME>` names: lower case, `_` as `-`. */
690
+ export function bucketNameOf(suffix) {
691
+ return suffix.toLowerCase().replaceAll("_", "-");
692
+ }
693
+ /** The `<NAME>` of a bucket's variables: upper case, `-` as `_`. */
694
+ export function bucketVariableSuffix(bucket) {
695
+ return bucket.toUpperCase().replaceAll("-", "_");
696
+ }
697
+ /**
698
+ * The S3 settings in `env`: the unnamed family `ANKKA_S3_{ENDPOINT,REGION,BUCKET,ACCESS_KEY,SECRET_KEY,PUBLIC_ENDPOINT}`;
699
+ * a named bucket the platform made, `ANKKA_S3_BUCKET_<NAME>`, `ANKKA_S3_EXPOSURE_<NAME>` and, for a public
700
+ * one, `ANKKA_S3_PUBLIC_URL_<NAME>`, sharing the unnamed family's endpoint, region and key; a supplied
701
+ * bucket, the five `ANKKA_S3_{ENDPOINT,REGION,BUCKET,ACCESS_KEY,SECRET_KEY}_<NAME>` of its own. Throws,
702
+ * naming the variable, on a family that cannot be read.
703
+ */
704
+ export function s3Settings(env = process.env, timeoutMs = 30_000) {
705
+ let unnamed;
706
+ const endpoint = nonEmpty(env, PREFIX + "ENDPOINT");
707
+ if (endpoint !== undefined) {
708
+ const access = nonEmpty(env, PREFIX + "ACCESS_KEY");
709
+ const secret = nonEmpty(env, PREFIX + "SECRET_KEY");
710
+ const publicEndpoint = nonEmpty(env, PREFIX + "PUBLIC_ENDPOINT");
711
+ unnamed = Object.freeze({
712
+ endpoint: parseUrl(PREFIX + "ENDPOINT", endpoint),
713
+ region: required(env, PREFIX + "REGION"),
714
+ bucket: env[PREFIX + "BUCKET"] ?? "",
715
+ credentials: access !== undefined && secret !== undefined ? new Credentials(access, secret) : undefined,
716
+ publicEndpoint: publicEndpoint === undefined ? undefined : parseUrl(PREFIX + "PUBLIC_ENDPOINT", publicEndpoint),
717
+ });
718
+ }
719
+ const suffixes = new Set();
720
+ for (const key of Object.keys(env)) {
721
+ for (const p of [PREFIX + "BUCKET_", PREFIX + "ENDPOINT_"]) {
722
+ if (key.startsWith(p) && key.length > p.length)
723
+ suffixes.add(key.slice(p.length));
724
+ }
725
+ }
726
+ const named = [];
727
+ for (const suffix of [...suffixes].sort()) {
728
+ const name = bucketNameOf(suffix);
729
+ const own = nonEmpty(env, `${PREFIX}ENDPOINT_${suffix}`);
730
+ if (own !== undefined) {
731
+ named.push(Object.freeze({
732
+ name,
733
+ exposure: "supplied",
734
+ target: Object.freeze({
735
+ endpoint: parseUrl(`${PREFIX}ENDPOINT_${suffix}`, own),
736
+ region: required(env, `${PREFIX}REGION_${suffix}`),
737
+ bucket: required(env, `${PREFIX}BUCKET_${suffix}`),
738
+ credentials: new Credentials(required(env, `${PREFIX}ACCESS_KEY_${suffix}`), required(env, `${PREFIX}SECRET_KEY_${suffix}`)),
739
+ publicEndpoint: undefined,
740
+ }),
741
+ publicUrl: undefined,
742
+ }));
743
+ continue;
744
+ }
745
+ const bucket = required(env, `${PREFIX}BUCKET_${suffix}`);
746
+ if (!unnamed)
747
+ throw new Error(`bucket '${name}' (${PREFIX}BUCKET_${suffix}) is made by the platform and ${PREFIX}ENDPOINT is not set`);
748
+ const word = nonEmpty(env, `${PREFIX}EXPOSURE_${suffix}`) ?? "private";
749
+ if (word !== "private" && word !== "signed" && word !== "public") {
750
+ throw new Error(`${PREFIX}EXPOSURE_${suffix} is '${word}'; it is one of private, signed or public`);
751
+ }
752
+ const publicUrl = nonEmpty(env, `${PREFIX}PUBLIC_URL_${suffix}`)?.replace(/\/$/, "");
753
+ if (word === "public" && publicUrl === undefined)
754
+ throw new Error(`bucket '${name}' is public and ${PREFIX}PUBLIC_URL_${suffix} is not set`);
755
+ named.push(Object.freeze({ name, exposure: word, target: Object.freeze({ ...unnamed, bucket }), publicUrl }));
756
+ }
757
+ return Object.freeze({ unnamed: unnamed && unnamed.bucket !== "" ? unnamed : undefined, named: Object.freeze(named), timeoutMs });
758
+ }
759
+ const httpAgent = new HttpAgent({ keepAlive: true });
760
+ const httpsAgent = new HttpsAgent({ keepAlive: true });
761
+ class Timeout extends Error {
762
+ }
763
+ /**
764
+ * One request over `node:http` or `node:https`, with `Content-Length` set when there is a body, which is
765
+ * piped chunk by chunk. A body that ends before its length aborts the request, so the store keeps nothing.
766
+ */
767
+ function send(url, method, headers, timeoutMs, body) {
768
+ const target = new URL(url);
769
+ const https = target.protocol === "https:";
770
+ return new Promise((resolvePromise, reject) => {
771
+ let settled = false;
772
+ const fail = (e) => {
773
+ if (settled)
774
+ return;
775
+ settled = true;
776
+ reject(e);
777
+ };
778
+ const req = (https ? httpsRequest : httpRequest)(url, { method, headers: body ? { ...headers, "Content-Length": String(body.length) } : headers, agent: https ? httpsAgent : httpAgent }, (response) => {
779
+ req.setTimeout(0);
780
+ if (settled) {
781
+ response.resume();
782
+ return;
783
+ }
784
+ settled = true;
785
+ resolvePromise({ status: response.statusCode ?? 0, response });
786
+ });
787
+ req.setTimeout(timeoutMs, () => req.destroy(new Timeout()));
788
+ req.on("error", fail);
789
+ if (!body) {
790
+ req.end();
791
+ return;
792
+ }
793
+ void (async () => {
794
+ let written = 0;
795
+ try {
796
+ for await (const chunk of body.chunks) {
797
+ written += chunk.length;
798
+ if (!req.write(chunk))
799
+ await new Promise((r) => req.once("drain", () => r()));
800
+ if (req.destroyed)
801
+ return;
802
+ }
803
+ }
804
+ catch (e) {
805
+ req.destroy();
806
+ fail(e);
807
+ return;
808
+ }
809
+ if (written < body.length) {
810
+ req.destroy();
811
+ fail(StorageRefusals.shortBody(body.length, written));
812
+ return;
813
+ }
814
+ req.end();
815
+ })();
816
+ });
817
+ }
818
+ async function textOf(response, limit = 1 << 20) {
819
+ const parts = [];
820
+ let total = 0;
821
+ for await (const chunk of response) {
822
+ if (total < limit)
823
+ parts.push(chunk);
824
+ total += chunk.length;
825
+ }
826
+ return Buffer.concat(parts).toString("utf8");
827
+ }
828
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
829
+ function unescapeXml(text) {
830
+ return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-z]+);/g, (whole, body) => {
831
+ if (body.startsWith("#x"))
832
+ return String.fromCodePoint(Number.parseInt(body.slice(2), 16));
833
+ if (body.startsWith("#"))
834
+ return String.fromCodePoint(Number.parseInt(body.slice(1), 10));
835
+ return ENTITIES[body] ?? whole;
836
+ });
837
+ }
838
+ function element(xml, tag) {
839
+ const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
840
+ return m ? unescapeXml(m[1]) : undefined;
841
+ }
842
+ /** A `ListObjectsV2` answer: its entries, and the token for the next page while it is truncated. */
843
+ export function parseListing(xml) {
844
+ const entries = [];
845
+ for (const m of xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
846
+ const block = m[1];
847
+ const key = element(block, "Key");
848
+ const size = element(block, "Size");
849
+ const modified = element(block, "LastModified");
850
+ if (key === undefined)
851
+ throw new Error("a listing's entry has no Key");
852
+ const length = size === undefined ? 0 : Number(size.trim());
853
+ if (!Number.isFinite(length))
854
+ throw new Error(`a listing's Size is not a number: ${size}`);
855
+ entries.push(Object.freeze({ name: key, length, lastKept: modified ? Instant.fromDate(new Date(modified.trim())) : Instant.ofEpochSecond(0) }));
856
+ }
857
+ const truncated = element(xml, "IsTruncated")?.trim() === "true";
858
+ return { entries, next: truncated ? element(xml, "NextContinuationToken") : undefined };
859
+ }
860
+ function lastModifiedOf(response) {
861
+ const v = response.headers["last-modified"];
862
+ const d = v ? new Date(v) : undefined;
863
+ return d && !Number.isNaN(d.getTime()) ? Instant.fromDate(d) : Instant.ofEpochSecond(0);
864
+ }
865
+ /** One bucket in an S3 store: path-style requests, a put streamed once with an unsigned payload, a listing paged as it is walked. */
866
+ export class S3Bucket {
867
+ name;
868
+ exposure;
869
+ #target;
870
+ #publicUrl;
871
+ #timeoutMs;
872
+ #endpoint;
873
+ constructor(target, name, exposure, publicUrl, timeoutMs = 30_000) {
874
+ this.#target = target;
875
+ this.name = name;
876
+ this.exposure = exposure;
877
+ this.#publicUrl = publicUrl;
878
+ this.#timeoutMs = timeoutMs;
879
+ this.#endpoint = target.endpoint.toString();
880
+ }
881
+ async put(objectName, body, options) {
882
+ ObjectRules.checkName(objectName);
883
+ const length = declaredLength(body, options);
884
+ const headers = { "Content-Type": options.contentType, "x-amz-content-sha256": UNSIGNED_PAYLOAD };
885
+ const { status, response } = await this.#send("PUT", this.#objectPath(objectName), [], headers, UNSIGNED_PAYLOAD, { chunks: bounded(body, length), length });
886
+ const text = await textOf(response);
887
+ if (status !== 200)
888
+ throw this.#refused(status, text);
889
+ }
890
+ async get(objectName) {
891
+ ObjectRules.checkName(objectName);
892
+ const { status, response } = await this.#send("GET", this.#objectPath(objectName));
893
+ if (status === 200) {
894
+ const length = Number(response.headers["content-length"] ?? -1);
895
+ const contentType = response.headers["content-type"] ?? "application/octet-stream";
896
+ return storedObject(contentType, Number.isFinite(length) ? length : -1, lastModifiedOf(response), Readable.toWeb(response));
897
+ }
898
+ const text = await textOf(response, 4096);
899
+ if (status === 404)
900
+ return undefined;
901
+ throw this.#refused(status, text);
902
+ }
903
+ async stat(objectName) {
904
+ ObjectRules.checkName(objectName);
905
+ const { status, response } = await this.#send("HEAD", this.#objectPath(objectName));
906
+ response.resume();
907
+ if (status === 200) {
908
+ const length = Number(response.headers["content-length"] ?? 0);
909
+ return Object.freeze({
910
+ name: objectName,
911
+ contentType: response.headers["content-type"] ?? "application/octet-stream",
912
+ length: Number.isFinite(length) ? length : 0,
913
+ lastKept: lastModifiedOf(response),
914
+ });
915
+ }
916
+ if (status === 404)
917
+ return undefined;
918
+ throw this.#refused(status, "");
919
+ }
920
+ async delete(objectName) {
921
+ ObjectRules.checkName(objectName);
922
+ const { status, response } = await this.#send("DELETE", this.#objectPath(objectName));
923
+ const text = await textOf(response);
924
+ if (status !== 200 && status !== 204 && status !== 404)
925
+ throw this.#refused(status, text);
926
+ }
927
+ list(prefix) {
928
+ ObjectRules.checkPrefix(prefix);
929
+ const bucket = this;
930
+ return (async function* () {
931
+ let token;
932
+ do {
933
+ const query = [
934
+ ["list-type", "2"],
935
+ ["prefix", prefix],
936
+ ];
937
+ if (token !== undefined)
938
+ query.push(["continuation-token", token]);
939
+ const { status, response } = await bucket.#send("GET", `/${bucket.#target.bucket}`, query);
940
+ const text = await textOf(response, 64 << 20);
941
+ if (status !== 200)
942
+ throw bucket.#refused(status, text);
943
+ let page;
944
+ try {
945
+ page = parseListing(text);
946
+ }
947
+ catch (e) {
948
+ throw StorageRefusals.unreadable(bucket.#endpoint, e instanceof Error ? e.message : String(e));
949
+ }
950
+ yield* page.entries;
951
+ token = page.next;
952
+ } while (token !== undefined);
953
+ })();
954
+ }
955
+ signedUrl(objectName, access, spanSeconds = ObjectRules.DEFAULT_SIGNED_URL_SECONDS) {
956
+ ObjectRules.checkName(objectName);
957
+ let base;
958
+ if (this.exposure === "supplied")
959
+ base = this.#target.endpoint;
960
+ else if (this.name === undefined || this.exposure === "signed") {
961
+ if (!this.#target.publicEndpoint)
962
+ throw StorageRefusals.notExposed();
963
+ base = this.#target.publicEndpoint;
964
+ }
965
+ else
966
+ throw StorageRefusals.notSigned(this.name, this.exposure);
967
+ ObjectRules.checkSpan(spanSeconds);
968
+ const method = access === "keep" ? "PUT" : "GET";
969
+ return presign(new SignedRequest(method, base, this.#objectPath(objectName)), this.#credentials(), this.#target.region, "s3", Math.trunc(spanSeconds), new Date());
970
+ }
971
+ url(objectName) {
972
+ ObjectRules.checkName(objectName);
973
+ if (this.exposure !== "public" || this.#publicUrl === undefined)
974
+ throw StorageRefusals.notPublic(this.name, this.exposure);
975
+ return this.#publicUrl.replace(/\/$/, "") + "/" + encode(objectName, false);
976
+ }
977
+ #credentials() {
978
+ if (!this.#target.credentials)
979
+ throw StorageRefusals.noCredential();
980
+ return this.#target.credentials;
981
+ }
982
+ #objectPath(objectName) {
983
+ return `/${this.#target.bucket}/${objectName}`;
984
+ }
985
+ async #send(method, path, query = [], headers = { "x-amz-content-sha256": EMPTY_PAYLOAD }, payload = EMPTY_PAYLOAD, body) {
986
+ const request = new SignedRequest(method, this.#target.endpoint, path, query);
987
+ const auth = sign(request, headers, payload, this.#credentials(), this.#target.region, "s3", new Date());
988
+ try {
989
+ return await send(request.url, method, { ...headers, ...auth }, this.#timeoutMs, body);
990
+ }
991
+ catch (e) {
992
+ if (e instanceof CommandError)
993
+ throw e;
994
+ const cause = e instanceof Timeout ? `no answer within ${Math.round(this.#timeoutMs / 1000)} seconds` : e instanceof Error ? e.message || e.name : String(e);
995
+ throw StorageRefusals.unreachable(this.#endpoint, cause);
996
+ }
997
+ }
998
+ #refused(status, body) {
999
+ return StorageRefusals.refusedByStore(this.#endpoint, status, /<Code>([^<]*)<\/Code>/.exec(body)?.[1] ?? "");
1000
+ }
1001
+ }
1002
+ /** The S3 store's buckets, by name, from `s3Settings`. */
1003
+ export class S3Buckets {
1004
+ #unnamed;
1005
+ #named;
1006
+ constructor(settings = s3Settings()) {
1007
+ const unnamedExposure = settings.unnamed?.publicEndpoint ? "signed" : "private";
1008
+ this.#unnamed = settings.unnamed && new S3Bucket(settings.unnamed, undefined, unnamedExposure, undefined, settings.timeoutMs);
1009
+ this.#named = new Map(settings.named.map((n) => [n.name, new S3Bucket(n.target, n.name, n.exposure, n.publicUrl, settings.timeoutMs)]));
1010
+ }
1011
+ get default() {
1012
+ if (!this.#unnamed)
1013
+ throw StorageRefusals.noBucket();
1014
+ return this.#unnamed;
1015
+ }
1016
+ bucket(name) {
1017
+ const b = this.#named.get(name);
1018
+ if (!b)
1019
+ throw StorageRefusals.unknown(name, this.#named.keys());
1020
+ return b;
1021
+ }
1022
+ get buckets() {
1023
+ return [...this.#named.values()];
1024
+ }
1025
+ }
1026
+ /**
1027
+ * The one place a process's store is chosen, as the runtime chooses its own: `ANKKA_STORAGE_BACKEND` when
1028
+ * set (`s3`, `directory` or `memory`); otherwise S3 when any `ANKKA_S3_*` bucket is given, the directory
1029
+ * store (`ANKKA_STORAGE_DIR`, default `.ankka/storage`) on a developer's machine — anywhere Kubernetes did
1030
+ * not start the process — and no store at all on the platform. Throws, naming the variable, when the
1031
+ * settings cannot be read: a service that cannot reach its buckets does not start.
1032
+ */
1033
+ export function chooseStorage(env = process.env, options = {}) {
1034
+ const word = (env.ANKKA_STORAGE_BACKEND ?? "").trim();
1035
+ const settings = s3Settings(env);
1036
+ const anyS3 = settings.unnamed !== undefined || settings.named.length > 0;
1037
+ const runningLocally = env.KUBERNETES_SERVICE_HOST === undefined;
1038
+ const local = () => declaredBuckets(options.serviceDirectory ?? process.cwd());
1039
+ let backend;
1040
+ switch (word) {
1041
+ case "":
1042
+ backend = anyS3 ? "s3" : runningLocally ? "directory" : "unavailable";
1043
+ break;
1044
+ case "s3":
1045
+ if (!anyS3)
1046
+ throw new Error(`ANKKA_STORAGE_BACKEND is s3 and ${PREFIX}ENDPOINT is not set: the s3 store needs an endpoint`);
1047
+ backend = "s3";
1048
+ break;
1049
+ case "directory":
1050
+ case "memory":
1051
+ backend = word;
1052
+ break;
1053
+ default:
1054
+ throw new Error(`ANKKA_STORAGE_BACKEND is '${word}'; it is one of s3, directory or memory`);
1055
+ }
1056
+ switch (backend) {
1057
+ case "s3":
1058
+ return { backend, buckets: new S3Buckets(settings) };
1059
+ case "directory": {
1060
+ const dir = env.ANKKA_STORAGE_DIR || ".ankka/storage";
1061
+ const root = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
1062
+ const declared = local();
1063
+ return { backend, buckets: new DirectoryBuckets(root, { ...(declared ? { declared } : {}), addressBase: options.addressBase ?? (() => localAddress(env)) }) };
1064
+ }
1065
+ case "memory": {
1066
+ const declared = local();
1067
+ return { backend, buckets: new InMemoryBuckets(declared ? { unnamed: declared.unnamed, buckets: declared.buckets } : {}) };
1068
+ }
1069
+ default:
1070
+ return { backend, buckets: noStorage };
1071
+ }
1072
+ }
1073
+ let processBuckets = noStorage;
1074
+ /** The store the server chose for this process, which every component's `storage` answers unless a test gave it one. */
1075
+ export function processStorage() {
1076
+ return processBuckets;
1077
+ }
1078
+ /** @internal The server's: the store this process was given. */
1079
+ export function _setProcessStorage(buckets) {
1080
+ processBuckets = buckets;
1081
+ }