@beignet/core 0.0.29 → 0.0.31

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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * @beignet/core/memo
3
+ *
4
+ * Request-scoped memoization for port lookups and other async functions.
5
+ *
6
+ * `createMemo(fn)` deduplicates calls with the same arguments for the
7
+ * lifetime of one request: the server enters a memo scope around every HTTP
8
+ * request and `runServiceContext(...)` execution, and the scope's cache dies
9
+ * with it. There is no TTL and no cross-request state, so memoized reads can
10
+ * never serve data staler than the request that fetched them.
11
+ *
12
+ * Outside any scope — plain scripts, `createServiceContext(...)` callers —
13
+ * memoized functions call straight through without caching.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+
18
+ /**
19
+ * Error thrown when a default cache key cannot be derived from arguments.
20
+ */
21
+ export class MemoKeyError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "MemoKeyError";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Instrumentation event emitted by memoized functions inside a scope that
30
+ * carries a recorder.
31
+ */
32
+ export interface MemoInstrumentationEvent {
33
+ /** Whether the call was served from the scope cache or filled it. */
34
+ kind: "hit" | "miss";
35
+ /** Memo name from `createMemo` options, defaulting to the function name. */
36
+ memo: string;
37
+ /** Encoded argument key for the call. */
38
+ key: string;
39
+ /** Fill duration for misses, rounded to two decimals. */
40
+ durationMs?: number;
41
+ /** Set when a miss's underlying call rejected (the entry is evicted). */
42
+ failed?: boolean;
43
+ }
44
+
45
+ type MemoScopeStore = {
46
+ entries: Map<string, unknown>;
47
+ record?: (event: MemoInstrumentationEvent) => void;
48
+ };
49
+
50
+ const memoScope = new AsyncLocalStorage<MemoScopeStore>();
51
+
52
+ /**
53
+ * Run a function inside a memo scope with an explicit recorder.
54
+ *
55
+ * Internal to the server runtime — apps should use `runMemoScope(...)`.
56
+ * Always uses `AsyncLocalStorage.run` (never `enterWith`), so callers'
57
+ * continuations never resume through a dangling frame.
58
+ */
59
+ export function runWithMemoScope<T>(
60
+ options: { record?: (event: MemoInstrumentationEvent) => void },
61
+ fn: () => T,
62
+ ): T {
63
+ return memoScope.run({ entries: new Map(), record: options.record }, fn);
64
+ }
65
+
66
+ /**
67
+ * Run a function inside a fresh memo scope.
68
+ *
69
+ * Use this in scripts and unit tests to give memoized functions a cache
70
+ * lifetime. Nested scopes start empty; an active recorder is inherited so
71
+ * instrumentation keeps flowing.
72
+ */
73
+ export function runMemoScope<T>(fn: () => T): T {
74
+ return runWithMemoScope({ record: memoScope.getStore()?.record }, fn);
75
+ }
76
+
77
+ /**
78
+ * Memoized function returned by `createMemo(...)`.
79
+ */
80
+ export type Memoized<F extends (...args: never[]) => unknown> = F & {
81
+ /**
82
+ * Drop the current scope's entry for these arguments, so the next call
83
+ * re-executes. Call this from mutation methods that make a memoized read
84
+ * stale within the same request.
85
+ *
86
+ * @returns Whether an entry existed.
87
+ */
88
+ invalidate(...args: Parameters<F>): boolean;
89
+ /**
90
+ * Drop all of this function's entries in the current scope.
91
+ */
92
+ clear(): void;
93
+ };
94
+
95
+ /**
96
+ * Options for `createMemo(...)`.
97
+ */
98
+ export interface CreateMemoOptions<F extends (...args: never[]) => unknown> {
99
+ /**
100
+ * Name used in instrumentation events and key errors. Defaults to the
101
+ * wrapped function's name.
102
+ */
103
+ name?: string;
104
+ /**
105
+ * Build the cache key from the call arguments. Defaults to a structural,
106
+ * type-tagged encoding of the arguments that throws `MemoKeyError` for
107
+ * values it cannot encode deterministically (functions, symbols, class
108
+ * instances, circular references).
109
+ */
110
+ key?: (...args: Parameters<F>) => string;
111
+ }
112
+
113
+ type EncodedMemoValue = readonly [string, ...unknown[]];
114
+
115
+ function describeArgType(value: unknown): string {
116
+ if (value === null) return "null";
117
+ if (typeof value !== "object") return typeof value;
118
+ const ctor = (value as object).constructor?.name;
119
+ return ctor && ctor !== "Object" ? `class instance (${ctor})` : "object";
120
+ }
121
+
122
+ function encodeMemoValue(
123
+ value: unknown,
124
+ memoName: string,
125
+ position: number,
126
+ seen: WeakSet<object>,
127
+ ): EncodedMemoValue {
128
+ if (value === null) return ["null"];
129
+
130
+ switch (typeof value) {
131
+ case "string":
132
+ return ["string", value];
133
+ case "number":
134
+ return ["number", Object.is(value, -0) ? "-0" : String(value)];
135
+ case "boolean":
136
+ return ["boolean", value ? "true" : "false"];
137
+ case "undefined":
138
+ return ["undefined"];
139
+ case "bigint":
140
+ return ["bigint", value.toString()];
141
+ case "object":
142
+ break;
143
+ default:
144
+ throw new MemoKeyError(
145
+ `[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} is a ${typeof value}. Pass options.key to compute keys for these arguments.`,
146
+ );
147
+ }
148
+
149
+ if (value instanceof Date) {
150
+ return ["date", value.toISOString()];
151
+ }
152
+
153
+ if (seen.has(value as object)) {
154
+ throw new MemoKeyError(
155
+ `[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} contains a circular reference. Pass options.key to compute keys for these arguments.`,
156
+ );
157
+ }
158
+
159
+ if (Array.isArray(value)) {
160
+ seen.add(value);
161
+ const encoded: EncodedMemoValue = [
162
+ "array",
163
+ value.map((item) => encodeMemoValue(item, memoName, position, seen)),
164
+ ];
165
+ seen.delete(value);
166
+ return encoded;
167
+ }
168
+
169
+ const proto = Object.getPrototypeOf(value);
170
+ if (proto !== null && proto !== Object.prototype) {
171
+ throw new MemoKeyError(
172
+ `[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} is a ${describeArgType(value)}. Pass options.key to compute keys for these arguments.`,
173
+ );
174
+ }
175
+
176
+ seen.add(value as object);
177
+ const record = value as Record<string, unknown>;
178
+ const encoded: EncodedMemoValue = [
179
+ "object",
180
+ Object.keys(record)
181
+ .sort()
182
+ .map((key) => [
183
+ key,
184
+ encodeMemoValue(record[key], memoName, position, seen),
185
+ ]),
186
+ ];
187
+ seen.delete(value as object);
188
+ return encoded;
189
+ }
190
+
191
+ function defaultArgsKey(memoName: string, args: readonly unknown[]): string {
192
+ return JSON.stringify(
193
+ args.map((arg, index) =>
194
+ encodeMemoValue(arg, memoName, index, new WeakSet()),
195
+ ),
196
+ );
197
+ }
198
+
199
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
200
+ return (
201
+ value !== null &&
202
+ (typeof value === "object" || typeof value === "function") &&
203
+ typeof (value as { then?: unknown }).then === "function"
204
+ );
205
+ }
206
+
207
+ function roundDuration(startedAt: number): number {
208
+ return Math.round((performance.now() - startedAt) * 100) / 100;
209
+ }
210
+
211
+ let nextMemoInstance = 0;
212
+
213
+ /**
214
+ * Wrap an async lookup so calls with the same arguments run once per request.
215
+ *
216
+ * Within a memo scope the first call executes and every later call with the
217
+ * same key returns the same value — including the same in-flight promise, so
218
+ * concurrent calls share one execution. Rejected promises are evicted, so a
219
+ * failed lookup is retried by the next call rather than memoized. Outside a
220
+ * scope the function calls through uncached.
221
+ *
222
+ * Memoize reads, not mutations. When a mutation in the same infra module
223
+ * makes a memoized read stale, call `memoized.invalidate(...)` with the
224
+ * read's arguments.
225
+ *
226
+ * @param fn - Function to memoize. `this` is not forwarded.
227
+ * @param options - Optional name and key builder.
228
+ * @returns The function with `invalidate(...)` and `clear()` attached.
229
+ */
230
+ export function createMemo<F extends (...args: never[]) => unknown>(
231
+ fn: F,
232
+ options: CreateMemoOptions<F> = {},
233
+ ): Memoized<F> {
234
+ const name = options.name ?? (fn.name || "anonymous");
235
+ // The map key is namespaced by instance, not name, so two memos that share
236
+ // a name can never read each other's entries. The unit separator keeps
237
+ // prefixes unambiguous: instance 1 never prefix-matches instance 11.
238
+ nextMemoInstance += 1;
239
+ const instancePrefix = `${nextMemoInstance}\u001f`;
240
+ const keyOf = (args: Parameters<F>): string =>
241
+ options.key ? options.key(...args) : defaultArgsKey(name, args);
242
+
243
+ const memoized = ((...args: Parameters<F>): unknown => {
244
+ const store = memoScope.getStore();
245
+ if (!store) return fn(...args);
246
+
247
+ const argsKey = keyOf(args);
248
+ const mapKey = instancePrefix + argsKey;
249
+ if (store.entries.has(mapKey)) {
250
+ store.record?.({ kind: "hit", memo: name, key: argsKey });
251
+ return store.entries.get(mapKey);
252
+ }
253
+
254
+ const startedAt = performance.now();
255
+ const value = fn(...args);
256
+ store.entries.set(mapKey, value);
257
+
258
+ if (isPromiseLike(value)) {
259
+ Promise.resolve(value).then(
260
+ () => {
261
+ store.record?.({
262
+ kind: "miss",
263
+ memo: name,
264
+ key: argsKey,
265
+ durationMs: roundDuration(startedAt),
266
+ });
267
+ },
268
+ () => {
269
+ if (store.entries.get(mapKey) === value) {
270
+ store.entries.delete(mapKey);
271
+ }
272
+ store.record?.({
273
+ kind: "miss",
274
+ memo: name,
275
+ key: argsKey,
276
+ durationMs: roundDuration(startedAt),
277
+ failed: true,
278
+ });
279
+ },
280
+ );
281
+ } else {
282
+ store.record?.({
283
+ kind: "miss",
284
+ memo: name,
285
+ key: argsKey,
286
+ durationMs: roundDuration(startedAt),
287
+ });
288
+ }
289
+
290
+ return value;
291
+ }) as Memoized<F>;
292
+
293
+ memoized.invalidate = (...args: Parameters<F>): boolean => {
294
+ const store = memoScope.getStore();
295
+ if (!store) return false;
296
+ return store.entries.delete(instancePrefix + keyOf(args));
297
+ };
298
+
299
+ memoized.clear = (): void => {
300
+ const store = memoScope.getStore();
301
+ if (!store) return;
302
+ for (const key of [...store.entries.keys()]) {
303
+ if (key.startsWith(instancePrefix)) {
304
+ store.entries.delete(key);
305
+ }
306
+ }
307
+ };
308
+
309
+ return memoized;
310
+ }
@@ -221,6 +221,9 @@ async function enforceRateLimit(
221
221
  resetAt: result.resetAt?.toISOString() ?? null,
222
222
  },
223
223
  "Rate limit exceeded",
224
+ result.retryAfterSeconds !== null
225
+ ? { headers: { "Retry-After": String(result.retryAfterSeconds) } }
226
+ : undefined,
224
227
  );
225
228
  }
226
229
 
@@ -231,7 +234,9 @@ async function enforceRateLimit(
231
234
  * in `onRequest` before context creation; user-scoped limits run in
232
235
  * `beforeHandle` after route hooks have resolved identity and `ctx.actor` is
233
236
  * available. Exceeded limits throw the framework `TooManyRequests` app error
234
- * with `scope`, `retryAfterSeconds`, and `resetAt` details. The bucket key is
237
+ * with `scope`, `retryAfterSeconds`, and `resetAt` details, and the 429
238
+ * response carries a `Retry-After` header when the limiter reports a reset
239
+ * time. The bucket key is
235
240
  * never sent to clients; denials emit a `rateLimit.denied` instrumentation
236
241
  * event that carries the key for operators.
237
242
  *
@@ -24,6 +24,10 @@ import {
24
24
  IdempotencyConflictError,
25
25
  IdempotencyInProgressError,
26
26
  } from "../idempotency/index.js";
27
+ import {
28
+ type MemoInstrumentationEvent,
29
+ runWithMemoScope,
30
+ } from "../memo/index.js";
27
31
  import type { AnyPorts } from "../ports/index.js";
28
32
  import {
29
33
  AuthUnauthorizedError,
@@ -32,10 +36,13 @@ import {
32
36
  isUnboundPort,
33
37
  TenantRequiredError,
34
38
  } from "../ports/index.js";
35
- import type {
36
- InferProviderPorts,
37
- ProviderSetupResult,
38
- ServiceProvider,
39
+ import {
40
+ createProviderInstrumentation,
41
+ type InferProviderPorts,
42
+ type ProviderInstrumentationTarget,
43
+ type ProviderSetupResult,
44
+ resolveProviderInstrumentationPort,
45
+ type ServiceProvider,
39
46
  } from "../providers/index.js";
40
47
  import type {
41
48
  ContextSeed,
@@ -1276,6 +1283,9 @@ function createRequestExecutor<
1276
1283
  ctx,
1277
1284
  response: {
1278
1285
  status: currentError.status,
1286
+ ...(currentError.headers
1287
+ ? { headers: { ...currentError.headers } }
1288
+ : {}),
1279
1289
  body: toErrorResponseBody(currentError),
1280
1290
  },
1281
1291
  error: currentError,
@@ -2123,6 +2133,41 @@ function createRequestExecutor<
2123
2133
  };
2124
2134
  }
2125
2135
 
2136
+ /**
2137
+ * Build a memo instrumentation recorder from the app ports, or undefined when
2138
+ * no instrumentation sink is wired.
2139
+ *
2140
+ * Resolved per execution rather than at route-build time because providers
2141
+ * contribute `ports.instrumentation`/`ports.devtools` during setup, after
2142
+ * routes are registered.
2143
+ */
2144
+ function createMemoScopeRecorder(
2145
+ ports: AnyPorts,
2146
+ ): ((event: MemoInstrumentationEvent) => void) | undefined {
2147
+ const target = ports as ProviderInstrumentationTarget;
2148
+ if (!resolveProviderInstrumentationPort(target)) return undefined;
2149
+
2150
+ const instrumentation = createProviderInstrumentation(target, {
2151
+ providerName: "memo",
2152
+ watcher: "memo",
2153
+ });
2154
+ return (event) => {
2155
+ instrumentation.custom({
2156
+ name: `memo.${event.kind}`,
2157
+ label: event.kind === "hit" ? "Memo hit" : "Memo fill",
2158
+ summary: `Memo ${event.kind} for ${event.memo}`,
2159
+ details: {
2160
+ memo: event.memo,
2161
+ key: event.key,
2162
+ ...(event.durationMs !== undefined
2163
+ ? { durationMs: event.durationMs }
2164
+ : {}),
2165
+ ...(event.failed ? { failed: true } : {}),
2166
+ },
2167
+ });
2168
+ };
2169
+ }
2170
+
2126
2171
  function buildHandler<
2127
2172
  Ctx,
2128
2173
  Ports extends AnyPorts,
@@ -2174,8 +2219,12 @@ function buildHandler<
2174
2219
  responseValidationExemptStatus,
2175
2220
  };
2176
2221
 
2222
+ // Every HTTP execution runs inside a fresh memo scope so createMemo(...)
2223
+ // wrappers dedupe lookups for exactly one request.
2177
2224
  return (req, preMatchedParams) =>
2178
- execute(executionTarget, req, preMatchedParams);
2225
+ runWithMemoScope({ record: createMemoScopeRecorder(finalPorts) }, () =>
2226
+ execute(executionTarget, req, preMatchedParams),
2227
+ );
2179
2228
  }
2180
2229
 
2181
2230
  /**
@@ -2319,19 +2368,28 @@ export async function createServer<
2319
2368
  // AsyncLocalStorage.run scopes the ambient frame to this callback, so the
2320
2369
  // caller's continuation never resumes through an enterWith frame — the
2321
2370
  // pattern that crashes Bun 1.3.x in plain scripts under top-level await.
2322
- return runWithActiveRequestContext(ambient, async () => {
2323
- const ctx = finalizeContext(
2324
- await serviceFactory({
2325
- ports: finalPorts,
2326
- input,
2327
- requestId,
2328
- trace,
2329
- }),
2330
- );
2331
- ambient.actor = readContextActor(ctx);
2332
- ambient.tenant = readContextTenant(ctx);
2333
- return await fn(ctx);
2334
- });
2371
+ // The memo scope shares the callback's lifetime, so createMemo(...)
2372
+ // wrappers dedupe lookups across the context factory and fn. The
2373
+ // createServiceContext(...) form has no such boundary and stays
2374
+ // unscoped: memoized functions call through uncached there.
2375
+ return runWithActiveRequestContext(ambient, () =>
2376
+ runWithMemoScope(
2377
+ { record: createMemoScopeRecorder(finalPorts) },
2378
+ async () => {
2379
+ const ctx = finalizeContext(
2380
+ await serviceFactory({
2381
+ ports: finalPorts,
2382
+ input,
2383
+ requestId,
2384
+ trace,
2385
+ }),
2386
+ );
2387
+ ambient.actor = readContextActor(ctx);
2388
+ ambient.tenant = readContextTenant(ctx);
2389
+ return await fn(ctx);
2390
+ },
2391
+ ),
2392
+ );
2335
2393
  };
2336
2394
  const contextRuntime = {
2337
2395
  createRequestContext,