bazis 0.98.9 → 0.98.11
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/core/app/runApp.ts +5 -0
- package/core/cache/SPEC.md +6 -4
- package/core/cache/cacheModule.ts +15 -4
- package/core/cache/distributed/codecs.ts +3 -0
- package/core/cache/http/CachedHttpPayload.ts +15 -4
- package/core/cache/http/missingCacheModuleCheck.ts +32 -0
- package/core/cache/http/outputCacheMiddleware.ts +1 -1
- package/core/cache/http/outputRedisCacheMiddleware.ts +1 -1
- package/core/cache/index.ts +1 -1
- package/core/di/ServiceProvider.ts +16 -2
- package/core/di/index.ts +2 -1
- package/core/di/internal/GraphValidator.ts +13 -2
- package/core/di/internal/ResolutionPlan.ts +2 -0
- package/core/di/module/autoDeps.ts +5 -2
- package/core/di/module/encapsulation.ts +2 -2
- package/core/di/module/moduleNameBinding.ts +10 -0
- package/core/di/module/providerSelection.ts +2 -2
- package/core/di/provider/index.ts +3 -0
- package/core/di/provider/types/OptionalDependency.ts +26 -0
- package/core/di/provider/types/ProviderDependencyList.ts +2 -0
- package/core/di/provider/types/ResolvedDeps.ts +4 -1
- package/core/di/provider/types/index.ts +1 -0
- package/core/http/Middleware/errorHandler.ts +19 -3
- package/core/http-client/index.ts +11 -7
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +33 -11
- package/library/http-client/HttpClient.ts +24 -1
- package/library/http-client/HttpClientFactory.ts +27 -17
- package/library/http-client/index.ts +1 -1
- package/library/http-client/serialize.ts +14 -0
- package/package.json +1 -1
package/core/app/runApp.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type BazisModuleRef,
|
|
9
9
|
} from "../di";
|
|
10
10
|
import { loadBazisGeneratedRuntime } from "../generatedRuntime";
|
|
11
|
+
import { missingCacheModuleCheck } from "../cache/http/missingCacheModuleCheck";
|
|
11
12
|
import { httpModule, useModelValidator, type HttpModuleOptions, type ModelValidator } from "../http";
|
|
12
13
|
import { grpcModule, type GrpcModuleOptions } from "../grpc";
|
|
13
14
|
import { buildHttpOpenApiDocument } from "../http/OpenApi/openApiDocument";
|
|
@@ -256,6 +257,10 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
|
|
|
256
257
|
if (options.cache !== undefined) {
|
|
257
258
|
globals.push(options.cache);
|
|
258
259
|
}
|
|
260
|
+
if (options.http !== undefined) {
|
|
261
|
+
// Warns about @OutputCache routes when no cache module is installed.
|
|
262
|
+
globals.push({ global: true, providers: [missingCacheModuleCheck()] });
|
|
263
|
+
}
|
|
259
264
|
const appRoot: BazisModuleRef =
|
|
260
265
|
globals.length > 0 || options.config !== undefined
|
|
261
266
|
? {
|
package/core/cache/SPEC.md
CHANGED
|
@@ -299,11 +299,11 @@ Infra separately publishes `DISTRIBUTED_CACHE_BACKEND`, `DISTRIBUTED_OUTPUT_CACH
|
|
|
299
299
|
|
|
300
300
|
## 5. Named policies
|
|
301
301
|
|
|
302
|
-
Policies are reusable presets in `
|
|
302
|
+
Policies are reusable presets in `memory({ policies })` (since 0.98.10; before only the internal `buildCacheModule`). A decorator refers to one with `policy: "name"`.
|
|
303
303
|
Inline decorator fields **override** the policy (policy → inline, inline wins).
|
|
304
304
|
|
|
305
305
|
```ts
|
|
306
|
-
|
|
306
|
+
memory({
|
|
307
307
|
policies: {
|
|
308
308
|
catalog: {
|
|
309
309
|
seconds: 60,
|
|
@@ -340,7 +340,7 @@ Caches the **full HTTP response** after the action runs. On a cache hit the cont
|
|
|
340
340
|
| Field | Type | Default | Description |
|
|
341
341
|
| --- | --- | --- | --- |
|
|
342
342
|
| `seconds` | `number` | — | **Required** (inline or in the policy). The entry TTL |
|
|
343
|
-
| `policy` | `string` | — | A policy name from `
|
|
343
|
+
| `policy` | `string` | — | A policy name from `memory({ policies })` |
|
|
344
344
|
| `tags` | `string[]` | — | Tags for `ICache.evictByTag(tag)` |
|
|
345
345
|
| `enabled` | `boolean` | `true` | `false`: the middleware is not attached (the metadata is kept) |
|
|
346
346
|
| `noStore` | `boolean` | `false` | `true`: neither read nor write the cache |
|
|
@@ -440,6 +440,8 @@ Sensitive response headers are **neither stored nor replayed**:
|
|
|
440
440
|
|
|
441
441
|
The body is read whole (`arrayBuffer`): **streaming responses are not supported**.
|
|
442
442
|
|
|
443
|
+
A response served from the cache carries `Age`: seconds since it was stored (since 0.98.10).
|
|
444
|
+
|
|
443
445
|
---
|
|
444
446
|
|
|
445
447
|
## 7. `@OutputRedisCache` — HTTP output cache (distributed)
|
|
@@ -1007,7 +1009,7 @@ without varyByUser or unlessAuthenticated — …
|
|
|
1007
1009
|
```
|
|
1008
1010
|
|
|
1009
1011
|
For a migration you can temporarily enable the warning mode:
|
|
1010
|
-
`
|
|
1012
|
+
`memory({ outputCache: { insecureAuthorizedRouteBehavior: "warn" } })`.
|
|
1011
1013
|
Disabling it fully (`"ignore"`) is acceptable only with an external security policy.
|
|
1012
1014
|
|
|
1013
1015
|
### 17.2. Recommendations by scenario
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import type { BazisModuleRef } from "../di";
|
|
2
|
-
import { buildCacheModule } from "./internal/buildCacheProviders";
|
|
2
|
+
import { buildCacheModule, type CacheOutputCacheOptions } from "./internal/buildCacheProviders";
|
|
3
3
|
import type { CacheOptions } from "./types/CacheOptions";
|
|
4
|
+
import type { CachePolicyRegistry } from "./types/CachePolicy";
|
|
5
|
+
|
|
6
|
+
/** Options of {@link memory}: the in-memory store plus named policies and output-cache settings. */
|
|
7
|
+
export interface MemoryCacheOptions extends CacheOptions {
|
|
8
|
+
/** Named policies shared by `@OutputCache({ policy })` and `@Cacheable({ policy })`. */
|
|
9
|
+
readonly policies?: CachePolicyRegistry;
|
|
10
|
+
/** HTTP output cache settings: global switch and the check of authorized routes. */
|
|
11
|
+
readonly outputCache?: CacheOutputCacheOptions;
|
|
12
|
+
}
|
|
4
13
|
|
|
5
14
|
/**
|
|
6
15
|
* In-memory cache as a one-line value. Inject {@link ICache} anywhere; decorate
|
|
7
16
|
* methods with `@Cacheable` or routes with `@OutputCache`.
|
|
8
17
|
*
|
|
9
18
|
* ```ts
|
|
10
|
-
*
|
|
19
|
+
* await runApp(AppModule, {
|
|
20
|
+
* cache: memory({ maxEntries: 1000, policies: { catalog: { seconds: 60, tags: ["catalog"] } } }),
|
|
21
|
+
* http: { port: 3000 },
|
|
22
|
+
* });
|
|
11
23
|
* // or, inside a module: imports: [memory()]
|
|
12
24
|
* ```
|
|
13
25
|
*
|
|
14
|
-
* Options are just the in-memory knobs (`maxEntries`, `defaultTtlSeconds`, …).
|
|
15
26
|
* For multi-instance caching keep `memory()` and add a distributed backend in your
|
|
16
27
|
* `@Infra` manifest via `redisConnect(redisConfig, { cache: "distributed" })`; `@CacheableRedis` /
|
|
17
28
|
* `@OutputRedisCache` then pick the backend up from DI automatically.
|
|
18
29
|
*/
|
|
19
|
-
export function memory(options:
|
|
30
|
+
export function memory(options: MemoryCacheOptions = {}): BazisModuleRef {
|
|
20
31
|
return buildCacheModule(options);
|
|
21
32
|
}
|
|
@@ -11,6 +11,7 @@ interface SerializedHttpPayload {
|
|
|
11
11
|
readonly status: number;
|
|
12
12
|
readonly headers: Readonly<Record<string, string>>;
|
|
13
13
|
readonly body: string;
|
|
14
|
+
readonly storedAt?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Codec for HTTP output payloads — body is base64 because JSON cannot hold bytes. */
|
|
@@ -20,6 +21,7 @@ export const httpPayloadCacheCodec: CacheCodec<CachedHttpPayload> = {
|
|
|
20
21
|
status: payload.status,
|
|
21
22
|
headers: payload.headers,
|
|
22
23
|
body: Buffer.from(payload.body).toString("base64"),
|
|
24
|
+
...(payload.storedAt !== undefined ? { storedAt: payload.storedAt } : {}),
|
|
23
25
|
} satisfies SerializedHttpPayload),
|
|
24
26
|
deserialize: (raw) => {
|
|
25
27
|
const parsed = JSON.parse(raw) as SerializedHttpPayload;
|
|
@@ -27,6 +29,7 @@ export const httpPayloadCacheCodec: CacheCodec<CachedHttpPayload> = {
|
|
|
27
29
|
status: parsed.status,
|
|
28
30
|
headers: parsed.headers,
|
|
29
31
|
body: Uint8Array.from(Buffer.from(parsed.body, "base64")),
|
|
32
|
+
...(typeof parsed.storedAt === "number" ? { storedAt: parsed.storedAt } : {}),
|
|
30
33
|
};
|
|
31
34
|
},
|
|
32
35
|
};
|
|
@@ -25,6 +25,8 @@ export interface CachedHttpPayload {
|
|
|
25
25
|
readonly status: number;
|
|
26
26
|
readonly headers: Readonly<Record<string, string>>;
|
|
27
27
|
readonly body: Uint8Array;
|
|
28
|
+
/** When the response was stored (epoch ms); a cache hit derives `Age` from it. */
|
|
29
|
+
readonly storedAt?: number;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface CachedHttpPayloadReadOptions {
|
|
@@ -105,8 +107,9 @@ export async function responseToCachedPayload(
|
|
|
105
107
|
headers[lower] = value;
|
|
106
108
|
}
|
|
107
109
|
});
|
|
110
|
+
const storedAt = Date.now();
|
|
108
111
|
if (response.body === null) {
|
|
109
|
-
return { status: response.status, headers, body: new Uint8Array() };
|
|
112
|
+
return { status: response.status, headers, body: new Uint8Array(), storedAt };
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
let clone: ReturnType<Response["clone"]>;
|
|
@@ -124,13 +127,21 @@ export async function responseToCachedPayload(
|
|
|
124
127
|
// The cached payload replaces the original response. Release its tee branch
|
|
125
128
|
// after the clone has been fully materialized.
|
|
126
129
|
await response.body.cancel().catch(() => undefined);
|
|
127
|
-
return { status: response.status, headers, body };
|
|
130
|
+
return { status: response.status, headers, body, storedAt };
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Rebuilds the stored response. `hit: true` marks a response served from the
|
|
135
|
+
* cache with `Age` — seconds since it was stored (RFC 9111).
|
|
136
|
+
*/
|
|
137
|
+
export function cachedPayloadToResponse(payload: CachedHttpPayload, options: { readonly hit?: boolean } = {}): Response {
|
|
138
|
+
const headers = stripSensitiveResponseHeaders(payload.headers);
|
|
139
|
+
if (options.hit && payload.storedAt !== undefined) {
|
|
140
|
+
headers.age = String(Math.max(0, Math.floor((Date.now() - payload.storedAt) / 1000)));
|
|
141
|
+
}
|
|
131
142
|
return new Response(payload.body as unknown as ConstructorParameters<typeof Response>[0], {
|
|
132
143
|
status: payload.status,
|
|
133
|
-
headers
|
|
144
|
+
headers,
|
|
134
145
|
});
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { singletonFactoryWithResolver, type ProviderDefinition } from "../../di";
|
|
2
|
+
import { ROUTE_MIDDLEWARE_COMPOSER, type RouteMiddlewareComposerRegistration } from "../../http";
|
|
3
|
+
import { LOGGER } from "../../kernel";
|
|
4
|
+
import { outputCacheMetaOf, resolveOutputCacheRequirement } from "../decorators/outputCacheMetadata";
|
|
5
|
+
import { ICache } from "../ICache";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A route composer the application composition installs next to the HTTP
|
|
9
|
+
* server: it adds no middleware and only warns at startup about each
|
|
10
|
+
* `@OutputCache` route when no cache module is installed — then the decorator
|
|
11
|
+
* does nothing and every request runs the action.
|
|
12
|
+
*/
|
|
13
|
+
export function missingCacheModuleCheck(): ProviderDefinition {
|
|
14
|
+
return singletonFactoryWithResolver(ROUTE_MIDDLEWARE_COMPOSER, [], (resolver): RouteMiddlewareComposerRegistration => {
|
|
15
|
+
const cacheInstalled = resolver.tryResolve(ICache) !== undefined;
|
|
16
|
+
const logger = resolver.tryResolve(LOGGER);
|
|
17
|
+
return {
|
|
18
|
+
order: 0,
|
|
19
|
+
compose: (controllerClass, methodName) => {
|
|
20
|
+
if (cacheInstalled) return [];
|
|
21
|
+
const requirement = resolveOutputCacheRequirement(outputCacheMetaOf(controllerClass), methodName);
|
|
22
|
+
if (requirement === undefined || requirement.enabled === false) return [];
|
|
23
|
+
const message = `[cache] @OutputCache on ${controllerClass.name}.${String(methodName)} has no effect: `
|
|
24
|
+
+ "no cache module is installed, so every request runs the action. "
|
|
25
|
+
+ "Add `cache: memory()` to the runApp options.";
|
|
26
|
+
if (logger) logger.warn(message);
|
|
27
|
+
else console.warn(message);
|
|
28
|
+
return [];
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -79,7 +79,7 @@ export function outputCacheMiddleware(options: OutputCacheMiddlewareOptions): Ht
|
|
|
79
79
|
const key = buildOutputCacheKey(ctx, routeName, config);
|
|
80
80
|
const cached = cache.get(key);
|
|
81
81
|
if (cached !== undefined) {
|
|
82
|
-
ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached), clientCache);
|
|
82
|
+
ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached, { hit: true }), clientCache);
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -83,7 +83,7 @@ export function outputRedisCacheMiddleware(options: OutputRedisCacheMiddlewareOp
|
|
|
83
83
|
const key = buildRedisOutputCacheKey(ctx, routeName, config);
|
|
84
84
|
const cached = await cache.get(key);
|
|
85
85
|
if (cached !== undefined) {
|
|
86
|
-
ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached), clientCache);
|
|
86
|
+
ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached, { hit: true }), clientCache);
|
|
87
87
|
return;
|
|
88
88
|
}
|
|
89
89
|
|
package/core/cache/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export {
|
|
|
59
59
|
DISTRIBUTED_SERVICE_CACHE,
|
|
60
60
|
} from "./tokens/DISTRIBUTED_CACHE";
|
|
61
61
|
|
|
62
|
-
export { memory } from "./cacheModule";
|
|
62
|
+
export { memory, type MemoryCacheOptions } from "./cacheModule";
|
|
63
63
|
/** @internal Advanced builder used by the hosting layer. */
|
|
64
64
|
export {
|
|
65
65
|
buildCacheModule,
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
isFactoryProvider,
|
|
37
37
|
isKeyedDependency,
|
|
38
38
|
isLazyDependency,
|
|
39
|
+
isOptionalDependency,
|
|
39
40
|
isNamedDependency,
|
|
40
41
|
isValueProvider,
|
|
41
42
|
} from "./provider";
|
|
@@ -78,6 +79,7 @@ export class ServiceProvider implements ServiceResolver {
|
|
|
78
79
|
? { required: provider.useClass.length, declared: this.getClassProviderDeps(provider).length }
|
|
79
80
|
: undefined,
|
|
80
81
|
describeDependency: (dependency) => this.getDependencyDescriptor(dependency),
|
|
82
|
+
hasName: (name) => this.registry.lookupName(name) !== undefined,
|
|
81
83
|
findRegistration: (token, key) => this.registry.find(token, key),
|
|
82
84
|
};
|
|
83
85
|
}
|
|
@@ -417,6 +419,9 @@ export class ServiceProvider implements ServiceResolver {
|
|
|
417
419
|
resolved[index] = this.createLazy(dependency.token, dependency.key, scopeState, ownerLifetime, stack);
|
|
418
420
|
continue;
|
|
419
421
|
}
|
|
422
|
+
if (dependency.optional && !this.registry.find(dependency.token, dependency.key)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
420
425
|
resolved[index] = await this.resolveSingleAsync(dependency.token, dependency.key, scopeState, stack, ownerLifetime);
|
|
421
426
|
}
|
|
422
427
|
return resolved;
|
|
@@ -560,6 +565,9 @@ export class ServiceProvider implements ServiceResolver {
|
|
|
560
565
|
if (dependency === undefined) {
|
|
561
566
|
continue;
|
|
562
567
|
}
|
|
568
|
+
if (dependency.optional && !this.registry.find(dependency.token, dependency.key)) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
563
571
|
resolved[index] = dependency.lazy
|
|
564
572
|
? this.createLazy(dependency.token, dependency.key, scopeState, ownerLifetime, stack)
|
|
565
573
|
: this.resolveSingle(dependency.token, dependency.key, scopeState, stack, ownerLifetime);
|
|
@@ -584,8 +592,14 @@ export class ServiceProvider implements ServiceResolver {
|
|
|
584
592
|
continue;
|
|
585
593
|
}
|
|
586
594
|
const lazy = isLazyDependency(dep);
|
|
587
|
-
const
|
|
588
|
-
|
|
595
|
+
const optional = isOptionalDependency(dep);
|
|
596
|
+
if (optional && isNamedDependency(dep.inner) && !this.registry.lookupName(dep.inner.name)) {
|
|
597
|
+
// An optional dependency on a name nothing registered stays unset.
|
|
598
|
+
plan[index] = undefined;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const descriptor = this.getDependencyDescriptor(lazy || optional ? dep.inner : dep);
|
|
602
|
+
plan[index] = { token: descriptor.token, key: descriptor.key, lazy, ...(optional ? { optional } : {}) };
|
|
589
603
|
}
|
|
590
604
|
this.planCache.set(provider, plan);
|
|
591
605
|
return plan;
|
package/core/di/index.ts
CHANGED
|
@@ -82,7 +82,7 @@ export {
|
|
|
82
82
|
type OpenGenericTokenFamily,
|
|
83
83
|
type Token,
|
|
84
84
|
} from "./token";
|
|
85
|
-
export { ProviderDefinition, keyedDependency, lazyDependency, namedDependency } from "./provider";
|
|
85
|
+
export { ProviderDefinition, keyedDependency, lazyDependency, namedDependency, optionalDependency } from "./provider";
|
|
86
86
|
export type {
|
|
87
87
|
AsyncFactoryProvider,
|
|
88
88
|
ClassProvider,
|
|
@@ -90,6 +90,7 @@ export type {
|
|
|
90
90
|
KeyedDependency,
|
|
91
91
|
Lazy,
|
|
92
92
|
LazyDependency,
|
|
93
|
+
OptionalDependency,
|
|
93
94
|
NamedDependency,
|
|
94
95
|
Provider,
|
|
95
96
|
ProviderDependencyList,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ServiceValidationError } from "../errors";
|
|
2
|
-
import { isLazyDependency } from "../provider";
|
|
2
|
+
import { isLazyDependency, isNamedDependency, isOptionalDependency } from "../provider";
|
|
3
3
|
import type {
|
|
4
4
|
KeyedDependency,
|
|
5
5
|
NamedDependency,
|
|
@@ -24,6 +24,8 @@ export interface GraphValidationContext {
|
|
|
24
24
|
dependencies(provider: Provider<unknown>): ProviderDependencyList;
|
|
25
25
|
/** Constructor arity vs declared deps for class providers; `undefined` for non-class providers. */
|
|
26
26
|
classShape(provider: Provider<unknown>): { readonly required: number; readonly declared: number } | undefined;
|
|
27
|
+
/** True when some registration carries this name (for optional named dependencies). */
|
|
28
|
+
hasName(name: string): boolean;
|
|
27
29
|
describeDependency(
|
|
28
30
|
dependency: Token<unknown> | KeyedDependency<unknown> | NamedDependency<unknown>,
|
|
29
31
|
): { readonly token: Token<unknown>; readonly key: ServiceKey | undefined };
|
|
@@ -88,8 +90,17 @@ export class GraphValidator {
|
|
|
88
90
|
continue;
|
|
89
91
|
}
|
|
90
92
|
const isLazy = isLazyDependency(dependency);
|
|
91
|
-
const
|
|
93
|
+
const isOptional = isOptionalDependency(dependency);
|
|
94
|
+
const inner = isLazy || isOptional ? dependency.inner : dependency;
|
|
95
|
+
// An optional dependency on a name nothing registered is simply absent.
|
|
96
|
+
if (isOptional && isNamedDependency(inner) && !this.context.hasName(inner.name)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const descriptor = this.context.describeDependency(inner);
|
|
92
100
|
const depRegistration = this.context.findRegistration(descriptor.token, descriptor.key);
|
|
101
|
+
if (!depRegistration && isOptional) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
93
104
|
if (!depRegistration) {
|
|
94
105
|
issues.add(
|
|
95
106
|
`Missing dependency "${tokenToDebugName(descriptor.token)}" for "${tokenToDebugName(registration.token)}"`,
|
|
@@ -13,6 +13,8 @@ export interface PlannedDependency {
|
|
|
13
13
|
readonly token: Token<unknown>;
|
|
14
14
|
readonly key: ServiceKey | undefined;
|
|
15
15
|
readonly lazy: boolean;
|
|
16
|
+
/** Resolve to `undefined` when the token has no registration. */
|
|
17
|
+
readonly optional?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export type ResolutionPlan = readonly (PlannedDependency | undefined)[];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { lazyDependency, namedDependency, type ProviderDependencyList } from "../provider";
|
|
1
|
+
import { lazyDependency, namedDependency, optionalDependency, type ProviderDependencyList } from "../provider";
|
|
2
2
|
import type { Class } from "../token";
|
|
3
3
|
|
|
4
4
|
const LAZY_PREFIX = "lazy:";
|
|
5
|
+
const OPTIONAL_PREFIX = "optional:";
|
|
5
6
|
// These registrations live for the process lifetime just like their
|
|
6
7
|
// constructors. A Map, rather than a WeakMap, lets the generated-runtime
|
|
7
8
|
// owner take an exact rollback snapshot before publishing a target slice.
|
|
@@ -24,7 +25,9 @@ export function getGeneratedClassDeps(useClass: Class<unknown>): ProviderDepende
|
|
|
24
25
|
typeof dependency !== "string" ? dependency
|
|
25
26
|
: dependency.startsWith(LAZY_PREFIX)
|
|
26
27
|
? lazyDependency(namedDependency(dependency.slice(LAZY_PREFIX.length)))
|
|
27
|
-
:
|
|
28
|
+
: dependency.startsWith(OPTIONAL_PREFIX)
|
|
29
|
+
? optionalDependency(namedDependency(dependency.slice(OPTIONAL_PREFIX.length)))
|
|
30
|
+
: namedDependency(dependency),
|
|
28
31
|
);
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ModuleEncapsulationError } from "../errors";
|
|
2
2
|
import { getProviderDeps } from "../internal/providerDeps";
|
|
3
3
|
import type { ProviderDefinition, ProviderDependencyList } from "../provider";
|
|
4
|
-
import { isKeyedDependency, isLazyDependency, isNamedDependency } from "../provider";
|
|
4
|
+
import { isKeyedDependency, isLazyDependency, isNamedDependency, isOptionalDependency } from "../provider";
|
|
5
5
|
import type { OpenGenericTokenFamily, Token } from "../token";
|
|
6
6
|
import { tokenToDebugName } from "../token";
|
|
7
7
|
import { applyNamedDependencyEncapsulationHooks } from "./encapsulationHooks";
|
|
@@ -249,7 +249,7 @@ function checkDependency(
|
|
|
249
249
|
|
|
250
250
|
// Laziness changes "when" a dependency is created, not "whether" the module
|
|
251
251
|
// may see it — validate the inner descriptor with the same rules.
|
|
252
|
-
if (isLazyDependency(dep)) {
|
|
252
|
+
if (isLazyDependency(dep) || isOptionalDependency(dep)) {
|
|
253
253
|
checkDependency(record, definition, dep.inner, context);
|
|
254
254
|
return;
|
|
255
255
|
}
|
|
@@ -3,7 +3,9 @@ import { getProviderDeps, withProviderDeps } from "../internal/providerDeps";
|
|
|
3
3
|
import {
|
|
4
4
|
isLazyDependency,
|
|
5
5
|
isNamedDependency,
|
|
6
|
+
isOptionalDependency,
|
|
6
7
|
lazyDependency,
|
|
8
|
+
optionalDependency,
|
|
7
9
|
ProviderDefinition,
|
|
8
10
|
} from "../provider";
|
|
9
11
|
import type { ProviderDependencyList } from "../provider";
|
|
@@ -118,6 +120,14 @@ function rebindDependency(dep: Dependency, nameMap: Map<string, Token<unknown>[]
|
|
|
118
120
|
}
|
|
119
121
|
return dep;
|
|
120
122
|
}
|
|
123
|
+
if (isOptionalDependency(dep)) {
|
|
124
|
+
const inner = dep.inner;
|
|
125
|
+
if (isNamedDependency(inner)) {
|
|
126
|
+
const token = resolveName(inner.name, nameMap);
|
|
127
|
+
return token ? optionalDependency(token) : dep;
|
|
128
|
+
}
|
|
129
|
+
return dep;
|
|
130
|
+
}
|
|
121
131
|
if (isNamedDependency(dep)) {
|
|
122
132
|
return resolveName(dep.name, nameMap) ?? dep;
|
|
123
133
|
}
|
|
@@ -2,7 +2,7 @@ import { ModuleEncapsulationError } from "../errors";
|
|
|
2
2
|
import { getProviderDeps } from "../internal/providerDeps";
|
|
3
3
|
import { NamedTokenIndex } from "../internal/NamedTokenIndex";
|
|
4
4
|
import type { OpenGenericRegistration } from "../internal/OpenGenericRegistration";
|
|
5
|
-
import { isKeyedDependency, isLazyDependency, isNamedDependency, type ProviderDefinition } from "../provider";
|
|
5
|
+
import { isKeyedDependency, isLazyDependency, isNamedDependency, isOptionalDependency, type ProviderDefinition } from "../provider";
|
|
6
6
|
import { tokenToDebugName, type Token } from "../token";
|
|
7
7
|
import type { ServiceKey } from "../types";
|
|
8
8
|
import { isFamilyExport, type ModuleGraphRecord } from "./encapsulation";
|
|
@@ -78,7 +78,7 @@ export function createProviderSelectionValidator(
|
|
|
78
78
|
return (definition, record) => {
|
|
79
79
|
for (const dependency of getProviderDeps(definition.provider)) {
|
|
80
80
|
if (dependency === undefined) continue;
|
|
81
|
-
const dep = isLazyDependency(dependency) ? dependency.inner : dependency;
|
|
81
|
+
const dep = isLazyDependency(dependency) || isOptionalDependency(dependency) ? dependency.inner : dependency;
|
|
82
82
|
const token = isNamedDependency(dep) ? names.lookup(dep.name) : isKeyedDependency(dep) ? dep.token : dep;
|
|
83
83
|
if (!token) continue; // Missing dependencies retain their normal DI error.
|
|
84
84
|
const key = isKeyedDependency(dep) ? dep.key : undefined;
|
|
@@ -18,9 +18,12 @@ export {
|
|
|
18
18
|
isNamedDependency,
|
|
19
19
|
lazyDependency,
|
|
20
20
|
isLazyDependency,
|
|
21
|
+
optionalDependency,
|
|
22
|
+
isOptionalDependency,
|
|
21
23
|
ProviderDefinition,
|
|
22
24
|
type KeyedDependency,
|
|
23
25
|
type Lazy,
|
|
24
26
|
type LazyDependency,
|
|
27
|
+
type OptionalDependency,
|
|
25
28
|
} from "./types";
|
|
26
29
|
export { isAsyncFactoryProvider, isClassProvider, isFactoryProvider, isValueProvider } from "./providerGuards";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Token } from "../../token";
|
|
2
|
+
import type { KeyedDependency } from "./KeyedDependency";
|
|
3
|
+
import type { NamedDependency } from "./NamedDependency";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional dependency: resolved when the token is registered, `undefined`
|
|
7
|
+
* otherwise. Codegen emits it for an optional constructor parameter
|
|
8
|
+
* (`cache?: ICache`, or one with a default value).
|
|
9
|
+
*/
|
|
10
|
+
export interface OptionalDependency<T> {
|
|
11
|
+
readonly optional: true;
|
|
12
|
+
readonly inner: Token<T> | KeyedDependency<T> | NamedDependency<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function optionalDependency<T>(inner: Token<T> | KeyedDependency<T> | NamedDependency<T>): OptionalDependency<T> {
|
|
16
|
+
return { optional: true, inner };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isOptionalDependency(value: unknown): value is OptionalDependency<unknown> {
|
|
20
|
+
return Boolean(
|
|
21
|
+
value &&
|
|
22
|
+
typeof value === "object" &&
|
|
23
|
+
(value as Record<string, unknown>).optional === true &&
|
|
24
|
+
"inner" in (value as Record<string, unknown>),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -2,10 +2,12 @@ import type { Token } from "../../token";
|
|
|
2
2
|
import type { KeyedDependency } from "./KeyedDependency";
|
|
3
3
|
import type { LazyDependency } from "./LazyDependency";
|
|
4
4
|
import type { NamedDependency } from "./NamedDependency";
|
|
5
|
+
import type { OptionalDependency } from "./OptionalDependency";
|
|
5
6
|
|
|
6
7
|
export type ProviderDependencyList = readonly (
|
|
7
8
|
| Token<unknown>
|
|
8
9
|
| KeyedDependency<unknown>
|
|
9
10
|
| NamedDependency<unknown>
|
|
10
11
|
| LazyDependency<unknown>
|
|
12
|
+
| OptionalDependency<unknown>
|
|
11
13
|
)[];
|
|
@@ -2,12 +2,15 @@ import type { Token } from "../../token";
|
|
|
2
2
|
import type { KeyedDependency } from "./KeyedDependency";
|
|
3
3
|
import type { Lazy, LazyDependency } from "./LazyDependency";
|
|
4
4
|
import type { NamedDependency } from "./NamedDependency";
|
|
5
|
+
import type { OptionalDependency } from "./OptionalDependency";
|
|
5
6
|
|
|
6
7
|
export type ResolvedDeps<
|
|
7
|
-
D extends readonly (Token<unknown> | KeyedDependency<unknown> | NamedDependency<unknown> | LazyDependency<unknown>)[],
|
|
8
|
+
D extends readonly (Token<unknown> | KeyedDependency<unknown> | NamedDependency<unknown> | LazyDependency<unknown> | OptionalDependency<unknown>)[],
|
|
8
9
|
> = {
|
|
9
10
|
[K in keyof D]: D[K] extends LazyDependency<infer TResolvedLazy>
|
|
10
11
|
? Lazy<TResolvedLazy>
|
|
12
|
+
: D[K] extends OptionalDependency<infer TResolvedOptional>
|
|
13
|
+
? TResolvedOptional | undefined
|
|
11
14
|
: D[K] extends Token<infer TResolved>
|
|
12
15
|
? TResolved
|
|
13
16
|
: D[K] extends KeyedDependency<infer TResolvedKeyed>
|
|
@@ -4,6 +4,7 @@ export type { FactoryProvider } from "./FactoryProvider";
|
|
|
4
4
|
export { keyedDependency, isKeyedDependency, type KeyedDependency } from "./KeyedDependency";
|
|
5
5
|
export { namedDependency, isNamedDependency, type NamedDependency } from "./NamedDependency";
|
|
6
6
|
export { lazyDependency, isLazyDependency, type Lazy, type LazyDependency } from "./LazyDependency";
|
|
7
|
+
export { optionalDependency, isOptionalDependency, type OptionalDependency } from "./OptionalDependency";
|
|
7
8
|
export type { ProviderDependencyList } from "./ProviderDependencyList";
|
|
8
9
|
export { ProviderDefinition } from "./ProviderDefinition";
|
|
9
10
|
export type { ProviderLifetime } from "./ProviderLifetime";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { REQUEST_ID_STATE_KEY, type Logger } from "../../kernel";
|
|
2
2
|
import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
|
|
3
|
+
import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
|
|
3
4
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
4
5
|
import { HttpError } from "../Errors/HttpError";
|
|
5
6
|
import type { HttpMiddleware } from "./types";
|
|
@@ -124,15 +125,30 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
|
|
|
124
125
|
return;
|
|
125
126
|
}
|
|
126
127
|
safelyReportUnexpected(options, log, ctx, error);
|
|
128
|
+
const [status, title] = unexpectedStatus(error);
|
|
127
129
|
ctx.response = options.exposeDetails
|
|
128
|
-
? safeJsonResponse(
|
|
129
|
-
error:
|
|
130
|
+
? safeJsonResponse(status, {
|
|
131
|
+
error: title,
|
|
130
132
|
// Development only, but redacted like the log: a secret in an error
|
|
131
133
|
// message must not reach a browser or a shared screenshot.
|
|
132
134
|
message: redactSensitiveText(error instanceof Error ? error.message : String(error)),
|
|
133
135
|
stack: error instanceof Error && error.stack !== undefined ? redactSensitiveText(error.stack) : undefined,
|
|
134
136
|
})
|
|
135
|
-
: safeJsonResponse(
|
|
137
|
+
: safeJsonResponse(status, { error: title });
|
|
136
138
|
}
|
|
137
139
|
};
|
|
138
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* An unhandled outbound HTTP failure is the upstream's fault, not ours:
|
|
144
|
+
* 504 when the call timed out, 502 otherwise. A client error without a code
|
|
145
|
+
* is a misconfiguration of our request (for example an invalid maxRedirects)
|
|
146
|
+
* and stays 500, like everything else. Upstream details never reach the
|
|
147
|
+
* client; the log keeps the full error.
|
|
148
|
+
*/
|
|
149
|
+
function unexpectedStatus(error: unknown): readonly [number, string] {
|
|
150
|
+
if (error instanceof HttpClientError && error.code !== undefined) {
|
|
151
|
+
return error.code === HttpErrorCode.Timeout ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
|
|
152
|
+
}
|
|
153
|
+
return [500, "Internal Server Error"];
|
|
154
|
+
}
|
|
@@ -16,8 +16,8 @@ import { DI, Module, createToken, type BazisModule, type ProviderDefinition } fr
|
|
|
16
16
|
import { getOutboundCorrelationHeaders } from "../kernel";
|
|
17
17
|
import {
|
|
18
18
|
HttpClient,
|
|
19
|
+
HttpClientFactory,
|
|
19
20
|
HttpClientFactoryBuilder,
|
|
20
|
-
type HttpClientFactory,
|
|
21
21
|
type HeaderBag,
|
|
22
22
|
type RequestConfig,
|
|
23
23
|
} from "../../library/http-client";
|
|
@@ -44,9 +44,10 @@ export interface HttpClientModuleConfig {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Registers the outbound HTTP client in DI with correlation propagation wired
|
|
47
|
-
* to the kernel request context:
|
|
48
|
-
* - {@link
|
|
49
|
-
* - {@link
|
|
47
|
+
* to the kernel request context. A service takes it by type:
|
|
48
|
+
* - `HttpClient` (or the {@link HTTP_CLIENT} token) — the default client;
|
|
49
|
+
* - `HttpClientFactory` (or {@link HTTP_CLIENT_FACTORY}) — named clients
|
|
50
|
+
* (fail-fast on unknown names).
|
|
50
51
|
*/
|
|
51
52
|
export function httpClientModule(config: HttpClientModuleConfig = {}): BazisModule {
|
|
52
53
|
const correlationHeaders = config.correlationHeaders ?? getOutboundCorrelationHeaders;
|
|
@@ -83,11 +84,14 @@ export function httpClientModule(config: HttpClientModuleConfig = {}): BazisModu
|
|
|
83
84
|
}
|
|
84
85
|
const factory = builder.build();
|
|
85
86
|
|
|
87
|
+
const client = factory.createClient();
|
|
88
|
+
// One factory and one default client, under the class (injection by type)
|
|
89
|
+
// and under the original tokens.
|
|
86
90
|
const providers: ProviderDefinition[] = [
|
|
91
|
+
DI.singleton(DI.valueProvider(HttpClientFactory, factory)),
|
|
87
92
|
DI.singleton(DI.valueProvider(HTTP_CLIENT_FACTORY, factory)),
|
|
88
|
-
DI.singleton(
|
|
89
|
-
|
|
90
|
-
),
|
|
93
|
+
DI.singleton(DI.valueProvider(HttpClient, client)),
|
|
94
|
+
DI.singleton(DI.valueProvider(HTTP_CLIENT, client)),
|
|
91
95
|
];
|
|
92
96
|
|
|
93
97
|
@Module({ providers })
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.11" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -152,7 +152,7 @@ const FRAMEWORK_INTERNAL_PREFIXES = FRAMEWORK_SOURCE === null ? [] : [
|
|
|
152
152
|
].map((prefix) => `${FRAMEWORK_SOURCE}/${prefix}`);
|
|
153
153
|
|
|
154
154
|
// A declaration is an identity; short names are local to their source/module.
|
|
155
|
-
interface CollectedDependency { readonly name: string; readonly target?: ts.ClassDeclaration; readonly lazy?: boolean; }
|
|
155
|
+
interface CollectedDependency { readonly name: string; readonly target?: ts.ClassDeclaration; readonly lazy?: boolean; readonly optional?: boolean; }
|
|
156
156
|
interface ConstructorDependencies {
|
|
157
157
|
readonly deps: CollectedDependency[];
|
|
158
158
|
readonly unsupportedInheritedParameter?: string;
|
|
@@ -619,7 +619,9 @@ function renderTargetDeps(): string {
|
|
|
619
619
|
}
|
|
620
620
|
}
|
|
621
621
|
const lines = ["// This file is auto-generated by `bazis codegen`.", "// Do not edit manually."];
|
|
622
|
-
|
|
622
|
+
const helpers = ["lazyDependency", "optionalDependency"].filter((helper) => entries.some(([, deps]) =>
|
|
623
|
+
deps.some((dep) => dep.target && (helper === "lazyDependency" ? dep.lazy : dep.optional))));
|
|
624
|
+
if (helpers.length > 0) lines.push(`import { ${helpers.join(", ")} } from "bazis/core/di";`);
|
|
623
625
|
for (const [declaration, reference] of references) {
|
|
624
626
|
const name = dependencyClassExportName(declaration);
|
|
625
627
|
if (!name) throw new Error(`BAZIS_DI_CLASS_UNIMPORTABLE: ${sourceLocation(declaration)}`);
|
|
@@ -628,9 +630,9 @@ function renderTargetDeps(): string {
|
|
|
628
630
|
lines.push("export const GENERATED_TARGET_CLASS_DEPS = Object.freeze([");
|
|
629
631
|
for (const [declaration, dependencies] of entries) {
|
|
630
632
|
const values = dependencies.map(dependency => {
|
|
631
|
-
if (!dependency.target) return JSON.stringify(dependency.name);
|
|
633
|
+
if (!dependency.target) return JSON.stringify(dependency.optional ? `optional:${dependency.name}` : dependency.name);
|
|
632
634
|
const reference = references.get(dependency.target)!;
|
|
633
|
-
return dependency.lazy ? `lazyDependency(${reference})` : reference;
|
|
635
|
+
return dependency.lazy ? `lazyDependency(${reference})` : dependency.optional ? `optionalDependency(${reference})` : reference;
|
|
634
636
|
});
|
|
635
637
|
lines.push(` [${references.get(declaration)}, [${values.join(", ")}]] as const,`);
|
|
636
638
|
}
|
|
@@ -762,7 +764,7 @@ function resolveConfigViewDeps(): void {
|
|
|
762
764
|
changed = false;
|
|
763
765
|
break;
|
|
764
766
|
}
|
|
765
|
-
resolved.push({ name: `Config:${prefixes[0]}
|
|
767
|
+
resolved.push({ name: `Config:${prefixes[0]}`, ...(dep.optional ? { optional: true } : {}) });
|
|
766
768
|
changed = true;
|
|
767
769
|
}
|
|
768
770
|
if (changed && classDeps.has(declaration)) classDeps.set(declaration, resolved);
|
|
@@ -782,6 +784,12 @@ function validateDepsAgainstKnownTokens(): void {
|
|
|
782
784
|
) {
|
|
783
785
|
continue;
|
|
784
786
|
}
|
|
787
|
+
// An optional trailing parameter of a type DI does not know (`options: Options = {}`)
|
|
788
|
+
// is not injected: the constructor gets its default, as without codegen.
|
|
789
|
+
if (deps[index]!.optional) {
|
|
790
|
+
classDeps.set(declaration, deps.slice(0, index));
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
785
793
|
classDeps.delete(declaration);
|
|
786
794
|
// A class DI must construct cannot lose a dependency silently: the app
|
|
787
795
|
// would fail later, far from the cause. Other classes need no entry.
|
|
@@ -935,8 +943,11 @@ function readInheritedConstructorDeps(node: ts.ClassDeclaration): ConstructorDep
|
|
|
935
943
|
}
|
|
936
944
|
}
|
|
937
945
|
const deps: CollectedDependency[] = [];
|
|
938
|
-
for (let index = 0; index
|
|
946
|
+
for (let index = 0; index < parameters.length; index++) {
|
|
939
947
|
const parameter = parameters[index]!;
|
|
948
|
+
const parameterDeclaration = parameter.valueDeclaration;
|
|
949
|
+
const optional = parameterDeclaration !== undefined && ts.isParameter(parameterDeclaration)
|
|
950
|
+
&& (parameterDeclaration.questionToken !== undefined || parameterDeclaration.initializer !== undefined);
|
|
940
951
|
let type = checker.getTypeOfSymbolAtLocation(parameter, node);
|
|
941
952
|
const declaration = parameter.valueDeclaration;
|
|
942
953
|
// A default before a required argument still occupies a DI position. The
|
|
@@ -945,9 +956,14 @@ function readInheritedConstructorDeps(node: ts.ClassDeclaration): ConstructorDep
|
|
|
945
956
|
const defined = type.types.filter((member) => !(member.flags & ts.TypeFlags.Undefined));
|
|
946
957
|
if (defined.length === 1) type = defined[0]!;
|
|
947
958
|
}
|
|
959
|
+
if (optional && type.isUnion()) {
|
|
960
|
+
const defined = type.types.filter((member) => !(member.flags & ts.TypeFlags.Undefined));
|
|
961
|
+
if (defined.length === 1) type = defined[0]!;
|
|
962
|
+
}
|
|
948
963
|
const dependency = dependencyFromType(type);
|
|
964
|
+
if (!dependency && index > lastRequired) break;
|
|
949
965
|
if (!dependency) return { deps: [], unsupportedInheritedParameter: parameter.name };
|
|
950
|
-
deps.push(dependency);
|
|
966
|
+
deps.push(optional && !dependency.lazy ? { ...dependency, optional: true } : dependency);
|
|
951
967
|
}
|
|
952
968
|
return { deps };
|
|
953
969
|
}
|
|
@@ -983,16 +999,22 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
|
|
|
983
999
|
|
|
984
1000
|
const deps: CollectedDependency[] = [];
|
|
985
1001
|
const lastRequiredParamIndex = findLastRequiredParameterIndex(ctor);
|
|
986
|
-
for (let index = 0; index
|
|
1002
|
+
for (let index = 0; index < ctor.parameters.length; index += 1) {
|
|
987
1003
|
const param = ctor.parameters[index];
|
|
988
1004
|
if (!param) {
|
|
989
1005
|
continue;
|
|
990
1006
|
}
|
|
1007
|
+
// Optional parameters (`cache?: ICache`, `clock: Clock = new SystemClock()`)
|
|
1008
|
+
// are injected when registered and left unset otherwise.
|
|
1009
|
+
const optional = param.questionToken !== undefined || param.initializer !== undefined;
|
|
1010
|
+
const trailing = index > lastRequiredParamIndex;
|
|
991
1011
|
const paramType = param.type;
|
|
992
1012
|
const refName = paramType ? getDependencyTypeName(paramType) : undefined;
|
|
993
1013
|
if (!refName || BUILTIN_DEPENDENCY_NAMES.has(refName)) {
|
|
1014
|
+
if (trailing) break;
|
|
994
1015
|
return { deps: [], unsupportedParameter: { index, name: param.name.getText(), type: paramType?.getText() ?? "(no type)" } };
|
|
995
1016
|
}
|
|
1017
|
+
const optionalFlag = optional ? { optional: true } : {};
|
|
996
1018
|
|
|
997
1019
|
// `Lazy<X>` is a deferred dependency on X: encode it with a marker prefix
|
|
998
1020
|
// so the runtime injects a Lazy wrapper instead of resolving X eagerly.
|
|
@@ -1010,7 +1032,7 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
|
|
|
1010
1032
|
const configType = paramType.typeArguments?.[0];
|
|
1011
1033
|
const configTypeName = configType ? getDependencyTypeName(configType) : undefined;
|
|
1012
1034
|
if (configTypeName) {
|
|
1013
|
-
deps.push({ name: `ConfigView<${configTypeName}
|
|
1035
|
+
deps.push({ name: `ConfigView<${configTypeName}>`, ...optionalFlag });
|
|
1014
1036
|
continue;
|
|
1015
1037
|
}
|
|
1016
1038
|
}
|
|
@@ -1019,12 +1041,12 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
|
|
|
1019
1041
|
const entityType = paramType.typeArguments?.[0];
|
|
1020
1042
|
const entityName = entityType ? getDependencyTypeName(entityType) : undefined;
|
|
1021
1043
|
if (entityName) {
|
|
1022
|
-
deps.push({ name: `IRepository<${entityName}
|
|
1044
|
+
deps.push({ name: `IRepository<${entityName}>`, ...optionalFlag });
|
|
1023
1045
|
continue;
|
|
1024
1046
|
}
|
|
1025
1047
|
}
|
|
1026
1048
|
|
|
1027
|
-
deps.push({ name: refName, target: dependencyClass(paramType!) });
|
|
1049
|
+
deps.push({ name: refName, target: dependencyClass(paramType!), ...optionalFlag });
|
|
1028
1050
|
}
|
|
1029
1051
|
|
|
1030
1052
|
return { deps };
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
appendQuery,
|
|
7
7
|
basicAuthHeader,
|
|
8
8
|
isBodyInit,
|
|
9
|
+
isAbsoluteUrl,
|
|
9
10
|
mergeHeaderBags,
|
|
11
|
+
pageUrl,
|
|
10
12
|
resolveUrl,
|
|
11
13
|
shouldPropagateCorrelation,
|
|
12
14
|
} from "./serialize";
|
|
@@ -188,7 +190,8 @@ export class HttpClient {
|
|
|
188
190
|
return { ...merged, method: String(override.method ?? base.method ?? "GET").toUpperCase() };
|
|
189
191
|
}
|
|
190
192
|
|
|
191
|
-
private async dispatch<T>(
|
|
193
|
+
private async dispatch<T>(requested: ResolvedRequestConfig): Promise<HttpResponse<T>> {
|
|
194
|
+
const config = withAbsoluteBaseUrl(requested);
|
|
192
195
|
if (config.inspectableRedirects !== undefined && typeof config.inspectableRedirects !== "boolean") {
|
|
193
196
|
throw new HttpClientError("inspectableRedirects must be a boolean", { config });
|
|
194
197
|
}
|
|
@@ -650,6 +653,26 @@ function isReplayableBody(body: RequestInit["body"]): boolean {
|
|
|
650
653
|
|| (typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
|
|
651
654
|
}
|
|
652
655
|
|
|
656
|
+
/**
|
|
657
|
+
* A relative `baseUrl` (`"/api"`) is resolved against the page address once,
|
|
658
|
+
* so URL building and every origin check below see an absolute base. Outside
|
|
659
|
+
* a page there is nothing to resolve it against.
|
|
660
|
+
*/
|
|
661
|
+
function withAbsoluteBaseUrl(config: ResolvedRequestConfig): ResolvedRequestConfig {
|
|
662
|
+
const { baseUrl } = config;
|
|
663
|
+
if (!baseUrl || isAbsoluteUrl(baseUrl)) {
|
|
664
|
+
return config;
|
|
665
|
+
}
|
|
666
|
+
const page = pageUrl();
|
|
667
|
+
if (page === undefined) {
|
|
668
|
+
throw new HttpClientError(
|
|
669
|
+
`baseUrl "${baseUrl}" is relative: outside a browser page it must be absolute, for example "http://localhost:3000${baseUrl.startsWith("/") ? "" : "/"}${baseUrl}"`,
|
|
670
|
+
{ config },
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
return { ...config, baseUrl: new URL(baseUrl, page).toString() };
|
|
674
|
+
}
|
|
675
|
+
|
|
653
676
|
function sameOrigin(left: string, right: string): boolean {
|
|
654
677
|
try {
|
|
655
678
|
return new URL(left).origin === new URL(right).origin;
|
|
@@ -2,9 +2,32 @@ import { HttpClient } from "./HttpClient";
|
|
|
2
2
|
import { HttpClientConfigError } from "./errors";
|
|
3
3
|
import type { RequestConfig } from "./types";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Resolves pre-configured named clients (à la .NET `IHttpClientFactory`).
|
|
7
|
+
* An abstract class, so a service can take it by type in its constructor.
|
|
8
|
+
*/
|
|
9
|
+
export abstract class HttpClientFactory {
|
|
10
|
+
public abstract createClient(name?: string): HttpClient;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class ConfiguredHttpClientFactory extends HttpClientFactory {
|
|
14
|
+
public constructor(
|
|
15
|
+
private readonly defaults: RequestConfig,
|
|
16
|
+
private readonly named: ReadonlyMap<string, RequestConfig>,
|
|
17
|
+
) {
|
|
18
|
+
super();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public createClient(name?: string): HttpClient {
|
|
22
|
+
if (name === undefined) {
|
|
23
|
+
return new HttpClient(this.defaults);
|
|
24
|
+
}
|
|
25
|
+
const config = this.named.get(name);
|
|
26
|
+
if (config === undefined) {
|
|
27
|
+
throw new HttpClientConfigError(name, [...this.named.keys()]);
|
|
28
|
+
}
|
|
29
|
+
return new HttpClient(this.defaults).create(config);
|
|
30
|
+
}
|
|
8
31
|
}
|
|
9
32
|
|
|
10
33
|
/**
|
|
@@ -27,19 +50,6 @@ export class HttpClientFactoryBuilder {
|
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
public build(): HttpClientFactory {
|
|
30
|
-
|
|
31
|
-
const defaults = this.defaults;
|
|
32
|
-
return {
|
|
33
|
-
createClient(name?: string): HttpClient {
|
|
34
|
-
if (name === undefined) {
|
|
35
|
-
return new HttpClient(defaults);
|
|
36
|
-
}
|
|
37
|
-
const config = named.get(name);
|
|
38
|
-
if (config === undefined) {
|
|
39
|
-
throw new HttpClientConfigError(name, [...named.keys()]);
|
|
40
|
-
}
|
|
41
|
-
return new HttpClient(defaults).create(config);
|
|
42
|
-
},
|
|
43
|
-
};
|
|
53
|
+
return new ConfiguredHttpClientFactory(this.defaults, new Map(this.named));
|
|
44
54
|
}
|
|
45
55
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { HttpClient } from "./HttpClient";
|
|
14
14
|
export { InspectableRedirectProtocol } from "./redirectProtocol";
|
|
15
|
-
export { HttpClientFactoryBuilder,
|
|
15
|
+
export { HttpClientFactoryBuilder, HttpClientFactory } from "./HttpClientFactory";
|
|
16
16
|
export { HttpClientError, HttpClientConfigError, HttpErrorCode } from "./errors";
|
|
17
17
|
export { InterceptorManager, type FulfilledFn, type RejectedFn } from "./interceptors";
|
|
18
18
|
export {
|
|
@@ -8,6 +8,20 @@ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
|
8
8
|
* prefix), `baseUrl: "https://api/v1"` + `"/users"` → `…/v1/users`.
|
|
9
9
|
* Absolute URLs and missing baseUrl pass through.
|
|
10
10
|
*/
|
|
11
|
+
export function isAbsoluteUrl(url: string): boolean {
|
|
12
|
+
return ABSOLUTE_URL.test(url);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The address of the current page in a browser (or worker), else undefined.
|
|
17
|
+
* A relative `baseUrl` such as `"/api"` is resolved against it, like axios's
|
|
18
|
+
* `baseURL` in a browser app.
|
|
19
|
+
*/
|
|
20
|
+
export function pageUrl(): string | undefined {
|
|
21
|
+
const location = (globalThis as { location?: { href?: unknown } }).location;
|
|
22
|
+
return typeof location?.href === "string" ? location.href : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export function resolveUrl(baseUrl: string | undefined, url: string): string {
|
|
12
26
|
if (!baseUrl || ABSOLUTE_URL.test(url)) {
|
|
13
27
|
return url;
|
package/package.json
CHANGED