@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.
- package/CHANGELOG.md +13 -0
- package/README.md +49 -2
- package/dist/errors/catalog.d.ts +8 -0
- package/dist/errors/catalog.d.ts.map +1 -1
- package/dist/errors/catalog.js +8 -0
- package/dist/errors/catalog.js.map +1 -1
- package/dist/memo/index.d.ts +107 -0
- package/dist/memo/index.d.ts.map +1 -0
- package/dist/memo/index.js +205 -0
- package/dist/memo/index.js.map +1 -0
- package/dist/server/hooks/rate-limit.d.ts +3 -1
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +6 -2
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/server.d.ts +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +46 -3
- package/dist/server/server.js.map +1 -1
- package/package.json +6 -1
- package/src/errors/catalog.ts +11 -1
- package/src/memo/index.ts +310 -0
- package/src/server/hooks/rate-limit.ts +6 -1
- package/src/server/server.ts +76 -18
|
@@ -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
|
|
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
|
*
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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,
|