@ciq-dev/neoiq-foundation-node 1.0.1-beta.2 → 1.0.1-beta.4

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/dist/index.mjs CHANGED
@@ -1,13 +1,9 @@
1
- import { createRequire } from "module";
2
- import { z } from "zod";
3
- import { AsyncLocalStorage } from "async_hooks";
4
- import { SpanStatusCode, SpanStatusCode as SpanStatusCode$1, context, context as context$1, metrics, propagation, propagation as propagation$1, trace, trace as trace$1 } from "@opentelemetry/api";
5
- import pino from "pino";
6
- import { NodeSDK } from "@opentelemetry/sdk-node";
7
- import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
8
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
1
+ import { AutoInstrumentationConfigSchema, FeaturesConfigSchema, FoundationConfigSchema, LoggingConfigSchema, OtelConfigSchema, RedactionConfigSchema, RequestLoggingConfigSchema, ShutdownConfigSchema, SpanStatusCode, context, getActiveSpan, getDefaultOtelEndpoint, getTraceContext, getTracer, isTracingEnabled, parseConfig, propagation, setupTracing, shutdownTracing, trace } from "./tracing-CcsyQIpB.mjs";
9
2
  import { resourceFromAttributes } from "@opentelemetry/resources";
10
3
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
4
+ import { SpanStatusCode as SpanStatusCode$1, context as context$1, metrics, propagation as propagation$1, trace as trace$1 } from "@opentelemetry/api";
5
+ import { AsyncLocalStorage } from "async_hooks";
6
+ import pino from "pino";
11
7
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc";
12
8
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
13
9
  import fp from "fastify-plugin";
@@ -15,90 +11,9 @@ import { randomUUID } from "crypto";
15
11
  import axios from "axios";
16
12
  import axiosRetry from "axios-retry";
17
13
  import CircuitBreaker from "opossum";
14
+ import { createHash } from "node:crypto";
18
15
  import { Readable } from "node:stream";
19
16
 
20
- //#region rolldown:runtime
21
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
22
-
23
- //#endregion
24
- //#region src/config.ts
25
- const AutoInstrumentationConfigSchema = z.object({
26
- http: z.boolean().default(true),
27
- fastify: z.boolean().default(true),
28
- express: z.boolean().default(true),
29
- mongodb: z.boolean().default(true),
30
- pg: z.boolean().default(true),
31
- mysql: z.boolean().default(true),
32
- redis: z.boolean().default(true),
33
- ioredis: z.boolean().default(true),
34
- grpc: z.boolean().default(true),
35
- fs: z.boolean().default(false),
36
- dns: z.boolean().default(false)
37
- }).partial();
38
- const FeaturesConfigSchema = z.object({
39
- tracing: z.boolean().default(true),
40
- metrics: z.boolean().default(true),
41
- logging: z.boolean().default(true),
42
- autoInstrumentation: AutoInstrumentationConfigSchema.default({})
43
- }).partial();
44
- const DEFAULT_OTEL_ENDPOINT = "http://otel-stack-deployment-collector.observability.svc.cluster.local:4317";
45
- const OtelConfigSchema = z.object({
46
- endpoint: z.string().default(DEFAULT_OTEL_ENDPOINT),
47
- metricsIntervalMs: z.number().min(1e3).default(5e3),
48
- traceSampleRate: z.number().min(0).max(1).default(1)
49
- }).partial();
50
- const LoggingConfigSchema = z.object({
51
- level: z.enum([
52
- "debug",
53
- "info",
54
- "warn",
55
- "error"
56
- ]).default("info"),
57
- prettyPrint: z.boolean().optional()
58
- }).partial();
59
- const RequestLoggingConfigSchema = z.object({
60
- logHeaders: z.boolean().default(true),
61
- logBody: z.boolean().default(false),
62
- logResponseBody: z.boolean().default(false),
63
- maxBodySize: z.number().default(10 * 1024),
64
- redactHeaders: z.array(z.string()).optional()
65
- }).partial();
66
- const RedactionConfigSchema = z.object({ additionalPaths: z.array(z.string()).default([]) }).partial();
67
- const ShutdownConfigSchema = z.object({
68
- flushOnCrash: z.boolean().default(false),
69
- flushTimeoutMs: z.number().min(100).default(5e3)
70
- }).partial();
71
- const FoundationConfigSchema = z.object({
72
- serviceName: z.string().min(1, "serviceName is required"),
73
- serviceVersion: z.string().default(process.env.SERVICE_VERSION || "1.0.0"),
74
- environment: z.enum([
75
- "development",
76
- "staging",
77
- "qa",
78
- "production"
79
- ]).default(process.env.NODE_ENV || "development"),
80
- features: FeaturesConfigSchema.default({}),
81
- otel: OtelConfigSchema.default({}),
82
- logging: LoggingConfigSchema.default({}),
83
- requestLogging: RequestLoggingConfigSchema.default({}),
84
- redaction: RedactionConfigSchema.default({}),
85
- shutdown: ShutdownConfigSchema.default({})
86
- });
87
- /** Parse and validate configuration */
88
- function parseConfig(input) {
89
- const result = FoundationConfigSchema.safeParse(input);
90
- if (!result.success) {
91
- const errors = result.error.errors.map((e) => ` - ${e.path.join(".")}: ${e.message}`).join("\n");
92
- throw new Error(`Invalid foundation configuration:\n${errors}`);
93
- }
94
- return result.data;
95
- }
96
- /** Get default OTEL endpoint */
97
- function getDefaultOtelEndpoint() {
98
- return process.env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_OTEL_ENDPOINT;
99
- }
100
-
101
- //#endregion
102
17
  //#region src/features/context.ts
103
18
  const BAGGAGE_CORRELATION_KEY = "correlation.id";
104
19
  function setBaggageCorrelationId(correlationId) {
@@ -385,12 +300,13 @@ function wrapPinoLogger(pinoLogger) {
385
300
  };
386
301
  }
387
302
  /** Fallback logger when Pino is not available */
388
- function createFallbackLogger(serviceName = "unknown") {
303
+ function createFallbackLogger(serviceName = "unknown", baseBindings = {}) {
389
304
  const log = (level, obj, msg) => {
390
305
  const logObj = {
391
306
  timestamp: new Date().toISOString(),
392
307
  level,
393
308
  service: serviceName,
309
+ ...baseBindings,
394
310
  ...obj,
395
311
  msg
396
312
  };
@@ -404,7 +320,10 @@ function createFallbackLogger(serviceName = "unknown") {
404
320
  info: (obj, msg) => log("info", obj, msg),
405
321
  warn: (obj, msg) => log("warn", obj, msg),
406
322
  error: (obj, msg) => log("error", obj, msg),
407
- child: () => fallback,
323
+ child: (bindings) => createFallbackLogger(serviceName, {
324
+ ...baseBindings,
325
+ ...bindings
326
+ }),
408
327
  pino: null
409
328
  };
410
329
  return fallback;
@@ -417,98 +336,6 @@ function getGlobalLogger() {
417
336
  return globalLogger || createFallbackLogger();
418
337
  }
419
338
 
420
- //#endregion
421
- //#region src/features/tracing.ts
422
- let sdk = null;
423
- let isInitialized$1 = false;
424
- const MONITORED_MODULES = [
425
- "pg",
426
- "mongodb",
427
- "ioredis",
428
- "mysql2",
429
- "express",
430
- "@grpc/grpc-js"
431
- ];
432
- function warnPreloadedModules() {
433
- for (const mod of MONITORED_MODULES) try {
434
- if (__require.resolve(mod) in (__require.cache || {})) console.warn(`[neoiq-foundation] "${mod}" was imported before tracing init. Auto-instrumentation may not work for this module. Use node -r @ciq-dev/neoiq-foundation-node/bootstrap or call createFoundation() first.`);
435
- } catch {}
436
- }
437
- /** Initialize OpenTelemetry tracing */
438
- function setupTracing(options) {
439
- if (isInitialized$1) {
440
- console.warn("[neoiq-foundation] Tracing already initialized");
441
- return;
442
- }
443
- warnPreloadedModules();
444
- const { serviceName, serviceVersion, environment, endpoint = getDefaultOtelEndpoint(), autoInstrumentation = {} } = options;
445
- const resource = resourceFromAttributes({
446
- [ATTR_SERVICE_NAME]: serviceName,
447
- [ATTR_SERVICE_VERSION]: serviceVersion,
448
- "deployment.environment": environment
449
- });
450
- const traceExporter = new OTLPTraceExporter({ url: endpoint });
451
- const instrumentationConfig = buildInstrumentationConfig(autoInstrumentation);
452
- sdk = new NodeSDK({
453
- resource,
454
- traceExporter,
455
- instrumentations: [getNodeAutoInstrumentations(instrumentationConfig)]
456
- });
457
- sdk.start();
458
- isInitialized$1 = true;
459
- }
460
- function buildInstrumentationConfig(config) {
461
- const mapping = {
462
- http: "@opentelemetry/instrumentation-http",
463
- fastify: "@opentelemetry/instrumentation-fastify",
464
- express: "@opentelemetry/instrumentation-express",
465
- mongodb: "@opentelemetry/instrumentation-mongodb",
466
- pg: "@opentelemetry/instrumentation-pg",
467
- mysql: "@opentelemetry/instrumentation-mysql",
468
- redis: "@opentelemetry/instrumentation-redis",
469
- ioredis: "@opentelemetry/instrumentation-ioredis",
470
- grpc: "@opentelemetry/instrumentation-grpc",
471
- fs: "@opentelemetry/instrumentation-fs",
472
- dns: "@opentelemetry/instrumentation-dns"
473
- };
474
- const result = {};
475
- for (const [key, instrumentationName] of Object.entries(mapping)) {
476
- const userSetting = config[key];
477
- const defaultValue = key !== "fs" && key !== "dns";
478
- result[instrumentationName] = { enabled: userSetting ?? defaultValue };
479
- }
480
- return result;
481
- }
482
- /** Shutdown tracing gracefully */
483
- async function shutdownTracing() {
484
- if (!sdk) return;
485
- try {
486
- await sdk.shutdown();
487
- isInitialized$1 = false;
488
- sdk = null;
489
- } catch (error) {
490
- console.error("[neoiq-foundation] Error shutting down tracing:", error);
491
- }
492
- }
493
- function getTracer(name) {
494
- return trace.getTracer(name);
495
- }
496
- function getActiveSpan() {
497
- return trace.getActiveSpan();
498
- }
499
- function getTraceContext() {
500
- const span = trace.getActiveSpan();
501
- if (!span) return {};
502
- const ctx = span.spanContext();
503
- return {
504
- traceId: ctx.traceId,
505
- spanId: ctx.spanId
506
- };
507
- }
508
- function isTracingEnabled() {
509
- return isInitialized$1;
510
- }
511
-
512
339
  //#endregion
513
340
  //#region src/features/metrics.ts
514
341
  let meterProvider = null;
@@ -546,6 +373,7 @@ async function shutdownMetrics() {
546
373
  meterProvider = null;
547
374
  } catch (error) {
548
375
  console.error("[neoiq-foundation] Error shutting down metrics:", error);
376
+ throw error;
549
377
  }
550
378
  }
551
379
  function getMeter(name, version = "1.0.0") {
@@ -823,31 +651,43 @@ function createHttpClient(options) {
823
651
  }
824
652
  return Promise.reject(error);
825
653
  });
826
- const retryConfig = {
827
- retries: retry.retries ?? 3,
828
- retryDelay: (retryCount) => (retry.retryDelay ?? 1e3) * Math.pow(2, retryCount - 1),
829
- retryCondition: (error) => {
830
- const retryStatusCodes = retry.retryStatusCodes ?? [
831
- 408,
832
- 429,
833
- 500,
834
- 502,
835
- 503,
836
- 504
837
- ];
838
- return !error.response || retryStatusCodes.includes(error.response?.status || 0);
839
- },
840
- onRetry: (retryCount, error, requestConfig) => {
841
- logger.warn({
842
- retryCount,
843
- url: `${requestConfig.baseURL || ""}${requestConfig.url}`,
844
- error: error.message
845
- }, "Retrying request");
846
- }
847
- };
848
- axiosRetry(client, retryConfig);
849
- if (cbOptions.enabled !== false) {
850
- const breaker = new CircuitBreaker(async (config) => client.request(config), {
654
+ if (retry.enabled !== false) {
655
+ const retryConfig = {
656
+ retries: retry.retries ?? 3,
657
+ retryDelay: (retryCount) => (retry.retryDelay ?? 1e3) * Math.pow(2, retryCount - 1),
658
+ retryCondition: (error) => {
659
+ const method = (error.config?.method ?? "get").toUpperCase();
660
+ const IDEMPOTENT = new Set([
661
+ "GET",
662
+ "HEAD",
663
+ "OPTIONS",
664
+ "PUT",
665
+ "DELETE"
666
+ ]);
667
+ if (!retry.retryNonIdempotent && !IDEMPOTENT.has(method)) return false;
668
+ const retryStatusCodes = retry.retryStatusCodes ?? [
669
+ 408,
670
+ 429,
671
+ 500,
672
+ 502,
673
+ 503,
674
+ 504
675
+ ];
676
+ return !error.response || retryStatusCodes.includes(error.response?.status || 0);
677
+ },
678
+ onRetry: (retryCount, error, requestConfig) => {
679
+ logger.warn({
680
+ retryCount,
681
+ url: `${requestConfig.baseURL || ""}${requestConfig.url}`,
682
+ error: error.message
683
+ }, "Retrying request");
684
+ }
685
+ };
686
+ axiosRetry(client, retryConfig);
687
+ }
688
+ if (cbOptions.enabled === true) {
689
+ const originalRequest = client.request.bind(client);
690
+ const breaker = new CircuitBreaker(async (config) => originalRequest(config), {
851
691
  timeout,
852
692
  resetTimeout: cbOptions.resetTimeout ?? 3e4,
853
693
  errorThresholdPercentage: cbOptions.errorThresholdPercentage ?? 50,
@@ -856,7 +696,6 @@ function createHttpClient(options) {
856
696
  breaker.on("open", () => logger.warn({ targetService: serviceName }, "Circuit breaker OPEN"));
857
697
  breaker.on("halfOpen", () => logger.info({ targetService: serviceName }, "Circuit breaker HALF-OPEN"));
858
698
  breaker.on("close", () => logger.info({ targetService: serviceName }, "Circuit breaker CLOSED"));
859
- const originalRequest = client.request.bind(client);
860
699
  client.request = (config) => breaker.fire(config);
861
700
  client.get = (url, config) => breaker.fire({
862
701
  ...config,
@@ -894,10 +733,15 @@ function createHttpClient(options) {
894
733
  //#endregion
895
734
  //#region src/foundation.ts
896
735
  const deprecationWarnings = new Set();
897
- function warnDeprecation(oldPath, newPath) {
736
+ function warnDeprecation(oldPath, newPath, logger) {
898
737
  if (deprecationWarnings.has(oldPath)) return;
899
738
  deprecationWarnings.add(oldPath);
900
- console.warn(`[neoiq-foundation] DEPRECATED: foundation.${oldPath}() is deprecated. Use foundation.${newPath}() instead. This alias will be removed in the next major version.`);
739
+ const msg = `foundation.${oldPath}() is deprecated. Use foundation.${newPath}() instead. This alias will be removed in the next major version.`;
740
+ if (logger) logger.warn({
741
+ deprecated: oldPath,
742
+ replacement: newPath
743
+ }, msg);
744
+ else console.warn(`[neoiq-foundation] DEPRECATED: ${msg}`);
901
745
  }
902
746
  /** Create a fully configured observability foundation */
903
747
  function createFoundation(input) {
@@ -955,7 +799,6 @@ function createFoundation(input) {
955
799
  serviceVersion,
956
800
  environment,
957
801
  endpoint: otel.endpoint,
958
- sampleRate: otel.traceSampleRate,
959
802
  autoInstrumentation: featuresConfig.autoInstrumentation
960
803
  });
961
804
  tracerInstance = getTracer(serviceName);
@@ -1058,9 +901,11 @@ function createFoundation(input) {
1058
901
  const metricsUp = !features.metrics || !metricsError && isMetricsEnabled();
1059
902
  const loggingUp = !loggingError;
1060
903
  const allUp = tracingUp && metricsUp && loggingUp;
1061
- const anyDown = features.tracing && tracingError || features.metrics && metricsError || loggingError;
904
+ const allDown = (!tracingUp || !features.tracing) && (!metricsUp || !features.metrics) && !loggingUp;
905
+ let status = "healthy";
906
+ if (!allUp) status = allDown ? "unhealthy" : "degraded";
1062
907
  return {
1063
- status: allUp ? "healthy" : anyDown ? "degraded" : "unhealthy",
908
+ status,
1064
909
  timestamp: new Date().toISOString(),
1065
910
  service: serviceName,
1066
911
  version: serviceVersion,
@@ -1134,43 +979,43 @@ function createFoundation(input) {
1134
979
  tracer: tracerInstance,
1135
980
  meter: meterInstance,
1136
981
  getTracer: (name) => {
1137
- warnDeprecation("getTracer", "observability.getTracer");
982
+ warnDeprecation("getTracer", "observability.getTracer", logger);
1138
983
  return observability.getTracer(name);
1139
984
  },
1140
985
  getMeter: (name, version) => {
1141
- warnDeprecation("getMeter", "observability.getMeter");
986
+ warnDeprecation("getMeter", "observability.getMeter", logger);
1142
987
  return observability.getMeter(name, version);
1143
988
  },
1144
989
  getTraceContext: () => {
1145
- warnDeprecation("getTraceContext", "observability.getTraceContext");
990
+ warnDeprecation("getTraceContext", "observability.getTraceContext", logger);
1146
991
  return observability.getTraceContext();
1147
992
  },
1148
993
  getActiveSpan: () => {
1149
- warnDeprecation("getActiveSpan", "observability.getActiveSpan");
994
+ warnDeprecation("getActiveSpan", "observability.getActiveSpan", logger);
1150
995
  return observability.getActiveSpan();
1151
996
  },
1152
997
  createHttpClient: (options) => {
1153
- warnDeprecation("createHttpClient", "http.createClient");
998
+ warnDeprecation("createHttpClient", "http.createClient", logger);
1154
999
  return httpModule.createClient(options);
1155
1000
  },
1156
1001
  shutdown: () => {
1157
- warnDeprecation("shutdown", "lifecycle.shutdown");
1002
+ warnDeprecation("shutdown", "lifecycle.shutdown", logger);
1158
1003
  return lifecycle.shutdown();
1159
1004
  },
1160
1005
  isReady: () => {
1161
- warnDeprecation("isReady", "lifecycle.isReady");
1006
+ warnDeprecation("isReady", "lifecycle.isReady", logger);
1162
1007
  return lifecycle.isReady();
1163
1008
  },
1164
1009
  health: () => {
1165
- warnDeprecation("health", "lifecycle.health");
1010
+ warnDeprecation("health", "lifecycle.health", logger);
1166
1011
  return lifecycle.health();
1167
1012
  },
1168
1013
  trace: (name, fn) => {
1169
- warnDeprecation("trace", "observability.trace");
1014
+ warnDeprecation("trace", "observability.trace", logger);
1170
1015
  return observability.trace(name, fn);
1171
1016
  },
1172
1017
  safeRun: (fn, fallback) => {
1173
- warnDeprecation("safeRun", "lifecycle.safeRun");
1018
+ warnDeprecation("safeRun", "lifecycle.safeRun", logger);
1174
1019
  return lifecycle.safeRun(fn, fallback);
1175
1020
  }
1176
1021
  };
@@ -1181,6 +1026,9 @@ const setupObservability = createFoundation;
1181
1026
 
1182
1027
  //#endregion
1183
1028
  //#region src/integrations/object-store/aws-s3.ts
1029
+ function isAwsError(err) {
1030
+ return typeof err === "object" && err !== null;
1031
+ }
1184
1032
  async function loadAwsS3() {
1185
1033
  try {
1186
1034
  const clientMod = await import("@aws-sdk/client-s3");
@@ -1195,13 +1043,10 @@ async function loadAwsS3() {
1195
1043
  getSignedUrl: presignerMod.getSignedUrl
1196
1044
  };
1197
1045
  } catch (err) {
1198
- const e = err;
1046
+ const e = err instanceof Error ? err : new Error(String(err));
1199
1047
  throw new Error(`AWS SDK not available. Install optional peer deps: @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner. Original error: ${e.message}`);
1200
1048
  }
1201
1049
  }
1202
- function normalizeBody(body) {
1203
- return body;
1204
- }
1205
1050
  /**
1206
1051
  * AwsS3ObjectStore - wraps AWS S3 behind the provider-agnostic `ObjectStore` interface.
1207
1052
  *
@@ -1230,7 +1075,7 @@ var AwsS3ObjectStore = class {
1230
1075
  const res = await s3.send(new aws.PutObjectCommand({
1231
1076
  Bucket: ref.bucket,
1232
1077
  Key: ref.key,
1233
- Body: normalizeBody(body),
1078
+ Body: body,
1234
1079
  ContentType: options.contentType,
1235
1080
  CacheControl: options.cacheControl,
1236
1081
  Metadata: options.metadata
@@ -1280,10 +1125,12 @@ var AwsS3ObjectStore = class {
1280
1125
  lastModified: res.LastModified
1281
1126
  };
1282
1127
  } catch (err) {
1283
- const e = err;
1284
- const name = String(e?.name || "");
1285
- const httpStatus = e?.$metadata?.httpStatusCode;
1286
- if (httpStatus === 404 || name.includes("NotFound") || name.includes("NoSuchKey")) return { exists: false };
1128
+ if (isAwsError(err)) {
1129
+ const name = String(err.name ?? "");
1130
+ if (name === "NoSuchBucket") throw err;
1131
+ const httpStatus = err.$metadata?.httpStatusCode;
1132
+ if (httpStatus === 404 || name.includes("NotFound") || name.includes("NoSuchKey")) return { exists: false };
1133
+ }
1287
1134
  throw err;
1288
1135
  }
1289
1136
  }
@@ -1304,8 +1151,9 @@ var AwsS3ObjectStore = class {
1304
1151
  ContinuationToken: options.continuationToken,
1305
1152
  MaxKeys: options.maxKeys
1306
1153
  }));
1154
+ const contents = res?.Contents ?? [];
1307
1155
  return {
1308
- objects: (res?.Contents ?? []).map((o) => ({
1156
+ objects: contents.filter((o) => typeof o.Key === "string").map((o) => ({
1309
1157
  key: o.Key,
1310
1158
  size: o.Size,
1311
1159
  etag: o.ETag,
@@ -1336,19 +1184,31 @@ var AwsS3ObjectStore = class {
1336
1184
 
1337
1185
  //#endregion
1338
1186
  //#region src/integrations/object-store/in-memory.ts
1187
+ const DEFAULT_MAX_OBJECTS = 1e4;
1188
+ const STREAM_TIMEOUT_MS = 3e4;
1339
1189
  function toBuffer(body) {
1340
1190
  if (typeof body === "string") return Buffer.from(body);
1341
1191
  if (Buffer.isBuffer(body)) return body;
1342
1192
  if (body instanceof Uint8Array) return Buffer.from(body);
1343
1193
  return new Promise((resolve, reject) => {
1344
1194
  const chunks = [];
1195
+ const timer = setTimeout(() => {
1196
+ body.destroy(new Error("InMemoryObjectStore: stream read timed out"));
1197
+ reject(new Error("InMemoryObjectStore: stream read timed out"));
1198
+ }, STREAM_TIMEOUT_MS);
1345
1199
  body.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
1346
- body.on("end", () => resolve(Buffer.concat(chunks)));
1347
- body.on("error", reject);
1200
+ body.on("end", () => {
1201
+ clearTimeout(timer);
1202
+ resolve(Buffer.concat(chunks));
1203
+ });
1204
+ body.on("error", (err) => {
1205
+ clearTimeout(timer);
1206
+ reject(err);
1207
+ });
1348
1208
  });
1349
1209
  }
1350
- function simpleEtag(buf) {
1351
- return `mem-${buf.length}-${buf.subarray(0, 8).toString("hex")}`;
1210
+ function computeEtag(buf) {
1211
+ return `"${createHash("md5").update(buf).digest("hex")}"`;
1352
1212
  }
1353
1213
  /**
1354
1214
  * InMemoryObjectStore - useful for local dev, unit tests, and as a safe default.
@@ -1357,6 +1217,11 @@ function simpleEtag(buf) {
1357
1217
  var InMemoryObjectStore = class {
1358
1218
  provider = "in-memory";
1359
1219
  buckets = new Map();
1220
+ maxObjects;
1221
+ objectCount = 0;
1222
+ constructor(options = {}) {
1223
+ this.maxObjects = options.maxObjects ?? DEFAULT_MAX_OBJECTS;
1224
+ }
1360
1225
  bucketMap(bucket) {
1361
1226
  let m = this.buckets.get(bucket);
1362
1227
  if (!m) {
@@ -1367,15 +1232,19 @@ var InMemoryObjectStore = class {
1367
1232
  }
1368
1233
  async putObject(ref, body, options = {}) {
1369
1234
  const buf = await toBuffer(body);
1235
+ const map = this.bucketMap(ref.bucket);
1236
+ const isNew = !map.has(ref.key);
1237
+ if (isNew && this.objectCount >= this.maxObjects) throw new Error(`InMemoryObjectStore: max object limit reached (${this.maxObjects})`);
1370
1238
  const obj = {
1371
1239
  body: buf,
1372
1240
  contentType: options.contentType,
1373
1241
  cacheControl: options.cacheControl,
1374
1242
  metadata: options.metadata,
1375
- etag: simpleEtag(buf),
1243
+ etag: computeEtag(buf),
1376
1244
  lastModified: new Date()
1377
1245
  };
1378
- this.bucketMap(ref.bucket).set(ref.key, obj);
1246
+ map.set(ref.key, obj);
1247
+ if (isNew) this.objectCount++;
1379
1248
  return { etag: obj.etag };
1380
1249
  }
1381
1250
  async getObject(ref) {
@@ -1407,18 +1276,31 @@ var InMemoryObjectStore = class {
1407
1276
  };
1408
1277
  }
1409
1278
  async deleteObject(ref) {
1410
- this.bucketMap(ref.bucket).delete(ref.key);
1279
+ const map = this.bucketMap(ref.bucket);
1280
+ if (map.delete(ref.key)) this.objectCount--;
1411
1281
  }
1412
1282
  async listObjects(options) {
1413
- const { bucket, prefix = "" } = options;
1283
+ const { bucket, prefix = "", continuationToken } = options;
1284
+ const maxKeys = Math.max(1, Math.floor(options.maxKeys ?? 1e3));
1414
1285
  const map = this.bucketMap(bucket);
1415
- const objects = [...map.entries()].filter(([key]) => key.startsWith(prefix)).map(([key, obj]) => ({
1286
+ const all = [...map.entries()].filter(([key]) => key.startsWith(prefix)).map(([key, obj]) => ({
1416
1287
  key,
1417
1288
  size: obj.body.length,
1418
1289
  etag: obj.etag,
1419
1290
  lastModified: obj.lastModified
1420
1291
  })).sort((a, b) => a.key.localeCompare(b.key));
1421
- return { objects };
1292
+ let startIndex = 0;
1293
+ if (continuationToken) {
1294
+ const idx = all.findIndex((o) => o.key === continuationToken);
1295
+ if (idx === -1) throw new Error(`InMemoryObjectStore: invalid continuationToken "${continuationToken}"`);
1296
+ startIndex = idx + 1;
1297
+ }
1298
+ const page = all.slice(startIndex, startIndex + maxKeys);
1299
+ const hasMore = startIndex + maxKeys < all.length;
1300
+ return {
1301
+ objects: page,
1302
+ nextContinuationToken: hasMore ? page[page.length - 1]?.key : void 0
1303
+ };
1422
1304
  }
1423
1305
  async presignGetObject(_ref, _options) {
1424
1306
  throw new Error("InMemoryObjectStore does not support presigned URLs");