@zmdb/web 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +49 -0
- package/dist/app/bridge.d.ts +12 -0
- package/dist/app/bridge.d.ts.map +1 -0
- package/dist/app/bridge.js +16 -0
- package/dist/app/bridge.js.map +1 -0
- package/dist/app/index.d.ts +23 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +47 -0
- package/dist/app/index.js.map +1 -0
- package/dist/bench/index.d.ts +68 -0
- package/dist/bench/index.d.ts.map +1 -0
- package/dist/bench/index.js +250 -0
- package/dist/bench/index.js.map +1 -0
- package/dist/compression/index.d.ts +13 -0
- package/dist/compression/index.d.ts.map +1 -0
- package/dist/compression/index.js +211 -0
- package/dist/compression/index.js.map +1 -0
- package/dist/context/index.d.ts +74 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +118 -0
- package/dist/context/index.js.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.js +86 -0
- package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.js +425 -0
- package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
- package/dist/contract/compiler/client.d.ts +12 -0
- package/dist/contract/compiler/client.d.ts.map +1 -0
- package/dist/contract/compiler/client.js +1293 -0
- package/dist/contract/compiler/client.js.map +1 -0
- package/dist/contract/compiler/index.d.ts +18 -0
- package/dist/contract/compiler/index.d.ts.map +1 -0
- package/dist/contract/compiler/index.js +1058 -0
- package/dist/contract/compiler/index.js.map +1 -0
- package/dist/contract/index.d.ts +237 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +22 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/csrf/index.d.ts +15 -0
- package/dist/csrf/index.d.ts.map +1 -0
- package/dist/csrf/index.js +218 -0
- package/dist/csrf/index.js.map +1 -0
- package/dist/data/index.d.ts +35 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +58 -0
- package/dist/data/index.js.map +1 -0
- package/dist/devtools/index.d.ts +74 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/index.js +552 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/dto-pipes/index.d.ts +45 -0
- package/dist/dto-pipes/index.d.ts.map +1 -0
- package/dist/dto-pipes/index.js +78 -0
- package/dist/dto-pipes/index.js.map +1 -0
- package/dist/gateways/index.d.ts +31 -0
- package/dist/gateways/index.d.ts.map +1 -0
- package/dist/gateways/index.js +138 -0
- package/dist/gateways/index.js.map +1 -0
- package/dist/health/index.d.ts +20 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +148 -0
- package/dist/health/index.js.map +1 -0
- package/dist/http-token.d.ts +2 -0
- package/dist/http-token.d.ts.map +1 -0
- package/dist/http-token.js +21 -0
- package/dist/http-token.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/index.d.ts +11 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +39 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/middleware/errors.d.ts +14 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +18 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/middleware/index.d.ts +46 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +64 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
- package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
- package/dist/modules/__fixtures__/large-graph.js +483 -0
- package/dist/modules/__fixtures__/large-graph.js.map +1 -0
- package/dist/openapi/__fixtures__/entities.d.ts +8 -0
- package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/entities.js +4 -0
- package/dist/openapi/__fixtures__/entities.js.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.js +17 -0
- package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
- package/dist/openapi/index.d.ts +61 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +314 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/pipeline/guards.d.ts +9 -0
- package/dist/pipeline/guards.d.ts.map +1 -0
- package/dist/pipeline/guards.js +11 -0
- package/dist/pipeline/guards.js.map +1 -0
- package/dist/pipeline/index.d.ts +141 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +1624 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/routing/index.d.ts +40 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +162 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/static/index.d.ts +14 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +348 -0
- package/dist/static/index.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/traced-router.d.ts +5 -0
- package/dist/traced-router.d.ts.map +1 -0
- package/dist/traced-router.js +6 -0
- package/dist/traced-router.js.map +1 -0
- package/dist/upload/index.d.ts +28 -0
- package/dist/upload/index.d.ts.map +1 -0
- package/dist/upload/index.js +410 -0
- package/dist/upload/index.js.map +1 -0
- package/dist/versioning/index.d.ts +35 -0
- package/dist/versioning/index.d.ts.map +1 -0
- package/dist/versioning/index.js +66 -0
- package/dist/versioning/index.js.map +1 -0
- package/dist/versioning/runtime.d.ts +5 -0
- package/dist/versioning/runtime.d.ts.map +1 -0
- package/dist/versioning/runtime.js +34 -0
- package/dist/versioning/runtime.js.map +1 -0
- package/package.json +143 -0
- package/src/app/bridge.ts +39 -0
- package/src/app/index.ts +76 -0
- package/src/bench/index.ts +329 -0
- package/src/compression/index.ts +255 -0
- package/src/context/index.ts +181 -0
- package/src/contract/__fixtures__/http-client.generated.ts +73 -0
- package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
- package/src/contract/__fixtures__/http-convergence.ts +564 -0
- package/src/contract/compiler/client.ts +1518 -0
- package/src/contract/compiler/index.ts +1457 -0
- package/src/contract/index.ts +266 -0
- package/src/csrf/index.ts +264 -0
- package/src/data/index.ts +65 -0
- package/src/devtools/index.ts +727 -0
- package/src/dto-pipes/index.ts +93 -0
- package/src/gateways/index.ts +180 -0
- package/src/health/index.ts +198 -0
- package/src/http-token.ts +22 -0
- package/src/index.ts +167 -0
- package/src/integrations/index.ts +50 -0
- package/src/middleware/errors.ts +17 -0
- package/src/middleware/index.ts +108 -0
- package/src/modules/__fixtures__/large-graph.ts +540 -0
- package/src/openapi/__fixtures__/entities.ts +19 -0
- package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
- package/src/openapi/__fixtures__/route-schemas.ts +27 -0
- package/src/openapi/index.ts +483 -0
- package/src/pipeline/guards.ts +23 -0
- package/src/pipeline/index.ts +2041 -0
- package/src/routing/index.ts +201 -0
- package/src/static/index.ts +443 -0
- package/src/testing/index.ts +58 -0
- package/src/traced-router.ts +8 -0
- package/src/upload/index.ts +489 -0
- package/src/versioning/index.ts +106 -0
- package/src/versioning/runtime.ts +38 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @zmdb/web — controllers & routing (epic #252, spec ./SPEC.md).
|
|
2
|
+
// Stage-3 class/method decorators store route data in context.metadata; getRoutes
|
|
3
|
+
// composes prefix + method paths. No reflect-metadata, no `as` on the consumer
|
|
4
|
+
// surface.
|
|
5
|
+
|
|
6
|
+
// Ensure Symbol.metadata exists before any decorated class in a consumer module
|
|
7
|
+
// is evaluated (Node 26/V8 lacks it). Side-effect import; safe/no-op if present.
|
|
8
|
+
import '@zmdb/app';
|
|
9
|
+
import type { HttpMethod } from '../contract/index.js';
|
|
10
|
+
|
|
11
|
+
export type { HttpMethod } from '../contract/index.js';
|
|
12
|
+
|
|
13
|
+
// Raw per-method record written by a verb decorator.
|
|
14
|
+
export interface RouteDefinition {
|
|
15
|
+
readonly method: HttpMethod;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly handlerName: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Prefix-composed route returned by getRoutes.
|
|
21
|
+
export interface ResolvedRoute {
|
|
22
|
+
readonly method: HttpMethod;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly handlerName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Symbol-keyed metadata slots. Symbols keep our data off the public string
|
|
28
|
+
// keyspace of context.metadata.
|
|
29
|
+
const ROUTES = Symbol('zmdb.web.routes');
|
|
30
|
+
const PREFIX = Symbol('zmdb.web.prefix');
|
|
31
|
+
const PUBLIC = Symbol('zmdb.web.public');
|
|
32
|
+
|
|
33
|
+
// A metadata record carrying our two slots. Both are optional until a decorator
|
|
34
|
+
// writes them.
|
|
35
|
+
interface RoutingMetadata {
|
|
36
|
+
[ROUTES]?: RouteDefinition[];
|
|
37
|
+
[PREFIX]?: string;
|
|
38
|
+
[PUBLIC]?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Read our routing view of a metadata record. `context.metadata` and the
|
|
42
|
+
// Symbol.metadata record are typed as an index of unknown; this is the single
|
|
43
|
+
// trust boundary where we assert our own private symbol slots exist with the
|
|
44
|
+
// shape we alone wrote. It is a typed *interface view*, not an `as` cast at the
|
|
45
|
+
// call sites.
|
|
46
|
+
// boundary: our decorators are the only writers of ROUTES/PREFIX, so viewing the
|
|
47
|
+
// record through RoutingMetadata is sound.
|
|
48
|
+
function routingView(metadata: DecoratorMetadata): RoutingMetadata {
|
|
49
|
+
return metadata;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Append a route to *this class's own* route list, creating it on first use.
|
|
53
|
+
//
|
|
54
|
+
// A subclass's metadata record is created with the base's as its prototype, so a
|
|
55
|
+
// plain `view[ROUTES]` read on a subclass returns the base's array. Pushing into
|
|
56
|
+
// it would write the subclass's route into the base — and therefore into every
|
|
57
|
+
// sibling subclass, since they all read through the same object. The own-property
|
|
58
|
+
// check keeps each class's declarations local; getRoutes recomposes the chain.
|
|
59
|
+
function pushRoute(metadata: DecoratorMetadata, route: RouteDefinition): void {
|
|
60
|
+
const view = routingView(metadata);
|
|
61
|
+
const own = Object.hasOwn(metadata, ROUTES) ? view[ROUTES] : undefined;
|
|
62
|
+
if (own === undefined) {
|
|
63
|
+
view[ROUTES] = [route];
|
|
64
|
+
} else {
|
|
65
|
+
own.push(route);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The routes one metadata record declares itself, ignoring anything it inherits.
|
|
70
|
+
function ownRoutes(metadata: DecoratorMetadata): readonly RouteDefinition[] {
|
|
71
|
+
if (!Object.hasOwn(metadata, ROUTES)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return routingView(metadata)[ROUTES] ?? [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ownPublicHandlers(metadata: DecoratorMetadata): readonly string[] {
|
|
78
|
+
if (!Object.hasOwn(metadata, PUBLIC)) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return routingView(metadata)[PUBLIC] ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Compose the metadata prototype chain base-first, layering each class's own
|
|
85
|
+
// routes over what it inherits. A class that declares any route for a handler
|
|
86
|
+
// replaces every inherited route for that same handler, so overriding a method to
|
|
87
|
+
// change its path renames the route instead of adding a second one; two verbs on
|
|
88
|
+
// one method are both own declarations, so both survive.
|
|
89
|
+
function composeRoutes(metadata: DecoratorMetadata): readonly RouteDefinition[] {
|
|
90
|
+
const baseFirst: DecoratorMetadata[] = [];
|
|
91
|
+
for (let record: DecoratorMetadata | null = metadata; record !== null; record = Object.getPrototypeOf(record)) {
|
|
92
|
+
baseFirst.unshift(record);
|
|
93
|
+
}
|
|
94
|
+
let composed: readonly RouteDefinition[] = [];
|
|
95
|
+
for (const record of baseFirst) {
|
|
96
|
+
const own = ownRoutes(record);
|
|
97
|
+
if (own.length === 0) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const renamed = new Set(own.map(route => route.handlerName));
|
|
101
|
+
composed = [...composed.filter(route => !renamed.has(route.handlerName)), ...own];
|
|
102
|
+
}
|
|
103
|
+
return composed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Normalize a composed path to a single leading slash, no duplicate slashes, and
|
|
107
|
+
// no trailing slash (except the root '/').
|
|
108
|
+
function normalizePath(prefix: string, path: string): string {
|
|
109
|
+
const joined = `/${prefix}/${path}`;
|
|
110
|
+
const collapsed = joined.replace(/\/+/g, '/');
|
|
111
|
+
if (collapsed.length > 1 && collapsed.endsWith('/')) {
|
|
112
|
+
return collapsed.slice(0, -1);
|
|
113
|
+
}
|
|
114
|
+
return collapsed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stage-3 class decorator: record the controller's path prefix. */
|
|
118
|
+
export function Controller(prefix = '') {
|
|
119
|
+
return function <T extends abstract new (...args: never[]) => unknown>(
|
|
120
|
+
_target: T,
|
|
121
|
+
context: ClassDecoratorContext<T>,
|
|
122
|
+
): void {
|
|
123
|
+
routingView(context.metadata)[PREFIX] = prefix;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Build a method decorator for a given HTTP verb.
|
|
128
|
+
function methodDecorator(method: HttpMethod) {
|
|
129
|
+
return function (path = '') {
|
|
130
|
+
return function (_target: (...args: never[]) => unknown, context: ClassMethodDecoratorContext): void {
|
|
131
|
+
const handlerName = typeof context.name === 'string' ? context.name : context.name.toString();
|
|
132
|
+
pushRoute(context.metadata, { method, path, handlerName });
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `@Get(path?)` route decorator. */
|
|
138
|
+
export const Get = methodDecorator('GET');
|
|
139
|
+
/** `@Post(path?)` route decorator. */
|
|
140
|
+
export const Post = methodDecorator('POST');
|
|
141
|
+
/** `@Put(path?)` route decorator. */
|
|
142
|
+
export const Put = methodDecorator('PUT');
|
|
143
|
+
/** `@Patch(path?)` route decorator. */
|
|
144
|
+
export const Patch = methodDecorator('PATCH');
|
|
145
|
+
/** `@Delete(path?)` route decorator. */
|
|
146
|
+
export const Delete = methodDecorator('DELETE');
|
|
147
|
+
|
|
148
|
+
/** Mark one route as intentionally unauthenticated for OpenAPI generation. */
|
|
149
|
+
export function Public() {
|
|
150
|
+
return function (_target: (...args: never[]) => unknown, context: ClassMethodDecoratorContext): void {
|
|
151
|
+
const handlerName = typeof context.name === 'string' ? context.name : context.name.toString();
|
|
152
|
+
const view = routingView(context.metadata);
|
|
153
|
+
const own = Object.hasOwn(context.metadata, PUBLIC) ? view[PUBLIC] : undefined;
|
|
154
|
+
if (own === undefined) {
|
|
155
|
+
view[PUBLIC] = [handlerName];
|
|
156
|
+
} else if (!own.includes(handlerName)) {
|
|
157
|
+
own.push(handlerName);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Whether the resolved handler is explicitly marked `@Public()`. */
|
|
163
|
+
export function isPublic(controller: abstract new (...args: never[]) => unknown, handlerName: string): boolean {
|
|
164
|
+
const metadata = controller[Symbol.metadata];
|
|
165
|
+
if (metadata === undefined || metadata === null) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
for (let record: DecoratorMetadata | null = metadata; record !== null; record = Object.getPrototypeOf(record)) {
|
|
169
|
+
if (ownRoutes(record).some(route => route.handlerName === handlerName)) {
|
|
170
|
+
return ownPublicHandlers(record).includes(handlerName);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve a controller class's routes: prefix composed with each method path,
|
|
178
|
+
* normalized, in declaration order. Reads context.metadata only — no reflection.
|
|
179
|
+
*
|
|
180
|
+
* A subclass gets the routes it inherits followed by its own, under its own
|
|
181
|
+
* prefix; `@Controller` on the subclass is optional, and without it the base's
|
|
182
|
+
* prefix is inherited too. A handler the subclass redeclares keeps only the
|
|
183
|
+
* subclass's path.
|
|
184
|
+
*/
|
|
185
|
+
export function getRoutes(controller: abstract new (...args: never[]) => unknown): readonly ResolvedRoute[] {
|
|
186
|
+
const metadata = controller[Symbol.metadata];
|
|
187
|
+
if (metadata === undefined || metadata === null) {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
const view = routingView(metadata);
|
|
191
|
+
const routes = composeRoutes(metadata);
|
|
192
|
+
if (routes.length === 0) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const prefix = view[PREFIX] ?? '';
|
|
196
|
+
return routes.map(route => ({
|
|
197
|
+
method: route.method,
|
|
198
|
+
path: normalizePath(prefix, route.path),
|
|
199
|
+
handlerName: route.handlerName,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// @zmdb/web — confined static-file responses (epic #564, issue #568).
|
|
2
|
+
//
|
|
3
|
+
// Paths are decoded once, refused before filesystem access, opened once, and
|
|
4
|
+
// streamed from that descriptor. Every refusal is the same empty 404; details
|
|
5
|
+
// go only to the required onError sink.
|
|
6
|
+
|
|
7
|
+
import type { Stats } from 'node:fs';
|
|
8
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
import { respond, stream, type WebResponse } from '../pipeline/index.js';
|
|
11
|
+
|
|
12
|
+
export interface StaticOptions {
|
|
13
|
+
readonly root: string;
|
|
14
|
+
readonly index?: string;
|
|
15
|
+
readonly cacheControl?: string;
|
|
16
|
+
readonly contentTypes?: Readonly<Record<string, string>>;
|
|
17
|
+
readonly onError: (error: unknown) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StaticHandler {
|
|
21
|
+
serve(pathname: string, headers: Readonly<Record<string, string>>): Promise<WebResponse>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
|
|
25
|
+
const NOSNIFF = Object.freeze({ 'x-content-type-options': 'nosniff' });
|
|
26
|
+
const DEFAULT_CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
|
|
27
|
+
'.css': 'text/css',
|
|
28
|
+
'.csv': 'text/csv',
|
|
29
|
+
'.gif': 'image/gif',
|
|
30
|
+
'.htm': 'text/html; charset=utf-8',
|
|
31
|
+
'.html': 'text/html; charset=utf-8',
|
|
32
|
+
'.ico': 'image/x-icon',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.jpg': 'image/jpeg',
|
|
35
|
+
'.js': 'application/javascript',
|
|
36
|
+
'.json': 'application/json',
|
|
37
|
+
'.mjs': 'application/javascript',
|
|
38
|
+
'.png': 'image/png',
|
|
39
|
+
'.svg': 'image/svg+xml',
|
|
40
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
41
|
+
'.webp': 'image/webp',
|
|
42
|
+
'.woff': 'font/woff',
|
|
43
|
+
'.woff2': 'font/woff2',
|
|
44
|
+
'.xml': 'application/xml',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
interface ByteRange {
|
|
48
|
+
readonly kind: 'range';
|
|
49
|
+
readonly start: number;
|
|
50
|
+
readonly end: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type ParsedRange = { readonly kind: 'full' } | { readonly kind: 'unsatisfiable' } | ByteRange;
|
|
54
|
+
|
|
55
|
+
interface StaticFilesystem {
|
|
56
|
+
readonly open: (path: string, flags: number) => Promise<FileHandle>;
|
|
57
|
+
readonly realpath: (path: string) => Promise<string>;
|
|
58
|
+
readonly stat: (path: string) => Promise<Stats>;
|
|
59
|
+
readonly readFlags: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface StaticPaths {
|
|
63
|
+
readonly extname: (path: string) => string;
|
|
64
|
+
readonly isAbsolute: (path: string) => boolean;
|
|
65
|
+
readonly resolve: (...paths: string[]) => string;
|
|
66
|
+
readonly sep: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a handler whose root is resolved and validated exactly once. */
|
|
70
|
+
export async function createStaticHandler(options: StaticOptions): Promise<StaticHandler> {
|
|
71
|
+
// Keep @zmdb/web and @zmdb/web/static loadable on Fetch-only runtimes. The
|
|
72
|
+
// Node filesystem is required only when an application constructs this
|
|
73
|
+
// filesystem-backed handler.
|
|
74
|
+
const [fsModule, fsPromises, pathModule] = await Promise.all([
|
|
75
|
+
import('node:fs'),
|
|
76
|
+
import('node:fs/promises'),
|
|
77
|
+
import('node:path'),
|
|
78
|
+
]);
|
|
79
|
+
const filesystem: StaticFilesystem = {
|
|
80
|
+
open: (path, flags) => fsPromises.open(path, flags),
|
|
81
|
+
realpath: path => fsPromises.realpath(path),
|
|
82
|
+
stat: path => fsPromises.stat(path),
|
|
83
|
+
readFlags: fsModule.constants.O_RDONLY | fsModule.constants.O_NONBLOCK,
|
|
84
|
+
};
|
|
85
|
+
const paths: StaticPaths = {
|
|
86
|
+
extname: pathModule.extname,
|
|
87
|
+
isAbsolute: pathModule.isAbsolute,
|
|
88
|
+
resolve: pathModule.resolve,
|
|
89
|
+
sep: pathModule.sep,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const root = await filesystem.realpath(options.root);
|
|
93
|
+
const rootStat = await filesystem.stat(root);
|
|
94
|
+
if (!rootStat.isDirectory()) {
|
|
95
|
+
throw new Error('static root must be a directory');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const cacheControl = options.cacheControl ?? DEFAULT_CACHE_CONTROL;
|
|
99
|
+
const contentTypes = normalizedContentTypes(options.contentTypes);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
serve(pathname: string, headers: Readonly<Record<string, string>>): Promise<WebResponse> {
|
|
103
|
+
return serveFromRoot(filesystem, paths, root, pathname, headers, cacheControl, contentTypes, options);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function serveFromRoot(
|
|
109
|
+
filesystem: StaticFilesystem,
|
|
110
|
+
paths: StaticPaths,
|
|
111
|
+
root: string,
|
|
112
|
+
pathname: string,
|
|
113
|
+
requestHeaders: Readonly<Record<string, string>>,
|
|
114
|
+
cacheControl: string,
|
|
115
|
+
contentTypes: Readonly<Record<string, string>>,
|
|
116
|
+
options: StaticOptions,
|
|
117
|
+
): Promise<WebResponse> {
|
|
118
|
+
const relative = confinedPath(pathname, options.index, paths);
|
|
119
|
+
if (relative.kind === 'missing') {
|
|
120
|
+
return notFound();
|
|
121
|
+
}
|
|
122
|
+
if (relative.kind === 'refused') {
|
|
123
|
+
report(options, relative.error);
|
|
124
|
+
return notFound();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const resolved = paths.resolve(root, relative.value);
|
|
128
|
+
if (!contains(root, resolved, paths.sep)) {
|
|
129
|
+
report(options, new Error('static path escaped its root'));
|
|
130
|
+
return notFound();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let handle: FileHandle;
|
|
134
|
+
try {
|
|
135
|
+
// O_NONBLOCK is load-bearing for FIFOs: opening one only to discover its
|
|
136
|
+
// inode type must not wait forever for a writer.
|
|
137
|
+
handle = await filesystem.open(resolved, filesystem.readFlags);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!isOrdinaryMiss(error)) {
|
|
140
|
+
report(options, error);
|
|
141
|
+
}
|
|
142
|
+
return notFound();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const descriptorStat = await handle.stat();
|
|
147
|
+
if (!descriptorStat.isFile()) {
|
|
148
|
+
report(options, new Error('static path is not a regular file'));
|
|
149
|
+
await closeQuietly(handle);
|
|
150
|
+
return notFound();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const target = await openedRealpath(filesystem, handle, resolved, descriptorStat);
|
|
154
|
+
if (!contains(root, target, paths.sep)) {
|
|
155
|
+
report(options, new Error('static symlink escaped its root'));
|
|
156
|
+
await closeQuietly(handle);
|
|
157
|
+
return notFound();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const validators = validatorHeaders(descriptorStat, cacheControl);
|
|
161
|
+
if (notModified(requestHeaders, descriptorStat, validators.etag)) {
|
|
162
|
+
await closeQuietly(handle);
|
|
163
|
+
return respond({ status: 304, headers: validators });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const requestedRange =
|
|
167
|
+
header(requestHeaders, 'if-range') === undefined
|
|
168
|
+
? parseRange(header(requestHeaders, 'range'), descriptorStat.size)
|
|
169
|
+
: { kind: 'full' as const };
|
|
170
|
+
|
|
171
|
+
if (requestedRange.kind === 'unsatisfiable') {
|
|
172
|
+
await closeQuietly(handle);
|
|
173
|
+
return respond({
|
|
174
|
+
status: 416,
|
|
175
|
+
headers: { ...validators, 'content-range': `bytes */${String(descriptorStat.size)}` },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const extension = paths.extname(relative.value).toLowerCase();
|
|
180
|
+
const contentType = contentTypes[extension] ?? 'application/octet-stream';
|
|
181
|
+
const start = requestedRange.kind === 'full' ? 0 : requestedRange.start;
|
|
182
|
+
const end = requestedRange.kind === 'full' ? Math.max(0, descriptorStat.size - 1) : requestedRange.end;
|
|
183
|
+
const length = requestedRange.kind === 'full' ? descriptorStat.size : end - start + 1;
|
|
184
|
+
const responseHeaders: Record<string, string> = {
|
|
185
|
+
...validators,
|
|
186
|
+
'accept-ranges': 'bytes',
|
|
187
|
+
'content-length': String(length),
|
|
188
|
+
'content-type': contentType,
|
|
189
|
+
};
|
|
190
|
+
if (requestedRange.kind !== 'full') {
|
|
191
|
+
responseHeaders['content-range'] = `bytes ${String(start)}-${String(end)}/${String(descriptorStat.size)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return stream(fileWindow(handle, start, length), {
|
|
195
|
+
status: requestedRange.kind === 'full' ? 200 : 206,
|
|
196
|
+
headers: responseHeaders,
|
|
197
|
+
length,
|
|
198
|
+
onError: options.onError,
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await closeQuietly(handle);
|
|
202
|
+
report(options, error);
|
|
203
|
+
return notFound();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type ConfinedPath =
|
|
208
|
+
| { readonly kind: 'path'; readonly value: string }
|
|
209
|
+
| { readonly kind: 'missing' }
|
|
210
|
+
| { readonly kind: 'refused'; readonly error: Error };
|
|
211
|
+
|
|
212
|
+
function confinedPath(pathname: string, index: string | undefined, paths: StaticPaths): ConfinedPath {
|
|
213
|
+
if (pathname.length === 0 && index === undefined) {
|
|
214
|
+
return { kind: 'missing' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let decoded: string;
|
|
218
|
+
try {
|
|
219
|
+
decoded = decodeURIComponent(pathname.length === 0 ? (index ?? '') : pathname);
|
|
220
|
+
} catch {
|
|
221
|
+
return { kind: 'refused', error: new Error('static path is not valid percent-encoded UTF-8') };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
decoded.length === 0 ||
|
|
226
|
+
decoded.includes('%') ||
|
|
227
|
+
decoded.includes('\0') ||
|
|
228
|
+
decoded.includes('\\') ||
|
|
229
|
+
decoded.startsWith('/') ||
|
|
230
|
+
decoded.startsWith('//') ||
|
|
231
|
+
/^[A-Za-z]:/.test(decoded) ||
|
|
232
|
+
paths.isAbsolute(decoded)
|
|
233
|
+
) {
|
|
234
|
+
return { kind: 'refused', error: new Error('static path contains a refused form') };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const segments = decoded.split('/');
|
|
238
|
+
if (segments.some(segment => segment.length === 0 || segment === '..' || segment.startsWith('.'))) {
|
|
239
|
+
return { kind: 'refused', error: new Error('static path contains a refused segment') };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { kind: 'path', value: decoded };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function contains(root: string, candidate: string, separator: string): boolean {
|
|
246
|
+
const prefix = root.endsWith(separator) ? root : `${root}${separator}`;
|
|
247
|
+
return candidate === root || candidate.startsWith(prefix);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizedContentTypes(
|
|
251
|
+
overrides: Readonly<Record<string, string>> | undefined,
|
|
252
|
+
): Readonly<Record<string, string>> {
|
|
253
|
+
if (overrides === undefined) {
|
|
254
|
+
return DEFAULT_CONTENT_TYPES;
|
|
255
|
+
}
|
|
256
|
+
const result: Record<string, string> = { ...DEFAULT_CONTENT_TYPES };
|
|
257
|
+
for (const key of Object.keys(overrides)) {
|
|
258
|
+
result[key.toLowerCase()] = overrides[key] ?? 'application/octet-stream';
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function openedRealpath(
|
|
264
|
+
filesystem: StaticFilesystem,
|
|
265
|
+
handle: FileHandle,
|
|
266
|
+
originalPath: string,
|
|
267
|
+
descriptorStat: Stats,
|
|
268
|
+
): Promise<string> {
|
|
269
|
+
try {
|
|
270
|
+
// Linux exposes the canonical target of the descriptor itself. The frozen
|
|
271
|
+
// tests already require /proc for descriptor accounting, and this form
|
|
272
|
+
// closes the path-swap gap between open() and the symlink containment check.
|
|
273
|
+
return await filesystem.realpath(`/proc/self/fd/${String(handle.fd)}`);
|
|
274
|
+
} catch {
|
|
275
|
+
// Other supported Node platforms have no portable fd-to-path API. The
|
|
276
|
+
// opened descriptor still owns response metadata and bytes. Bind the
|
|
277
|
+
// path-based real target back to that descriptor by filesystem identity so
|
|
278
|
+
// a swap between open() and realpath() is refused.
|
|
279
|
+
const target = await filesystem.realpath(originalPath);
|
|
280
|
+
const targetStat = await filesystem.stat(target);
|
|
281
|
+
if (!sameFile(descriptorStat, targetStat)) {
|
|
282
|
+
throw new Error('static path changed while it was being opened');
|
|
283
|
+
}
|
|
284
|
+
return target;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function sameFile(left: Stats, right: Stats): boolean {
|
|
289
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function validatorHeaders(statValue: Stats, cacheControl: string): Record<string, string> & { readonly etag: string } {
|
|
293
|
+
return {
|
|
294
|
+
'cache-control': cacheControl,
|
|
295
|
+
etag: `W/"${String(statValue.size)}-${String(statValue.mtimeMs)}"`,
|
|
296
|
+
'last-modified': statValue.mtime.toUTCString(),
|
|
297
|
+
'x-content-type-options': 'nosniff',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function notModified(headers: Readonly<Record<string, string>>, statValue: Stats, etag: string): boolean {
|
|
302
|
+
const ifNoneMatch = header(headers, 'if-none-match');
|
|
303
|
+
if (ifNoneMatch !== undefined) {
|
|
304
|
+
return ifNoneMatch
|
|
305
|
+
.split(',')
|
|
306
|
+
.map(value => value.trim())
|
|
307
|
+
.some(value => value === '*' || weakTag(value) === weakTag(etag));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const ifModifiedSince = header(headers, 'if-modified-since');
|
|
311
|
+
if (ifModifiedSince === undefined) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const since = Date.parse(ifModifiedSince);
|
|
315
|
+
return Number.isFinite(since) && Math.floor(statValue.mtimeMs / 1000) * 1000 <= since;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function weakTag(value: string): string {
|
|
319
|
+
const trimmed = value.trim();
|
|
320
|
+
return trimmed.startsWith('W/') ? trimmed.slice(2) : trimmed;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function parseRange(value: string | undefined, size: number): ParsedRange {
|
|
324
|
+
if (value === undefined || value.includes(',')) {
|
|
325
|
+
return { kind: 'full' };
|
|
326
|
+
}
|
|
327
|
+
const match = /^bytes=(\d*)-(\d*)$/i.exec(value.trim());
|
|
328
|
+
if (match === null) {
|
|
329
|
+
return { kind: 'full' };
|
|
330
|
+
}
|
|
331
|
+
const first = match[1] ?? '';
|
|
332
|
+
const second = match[2] ?? '';
|
|
333
|
+
if (first.length === 0 && second.length === 0) {
|
|
334
|
+
return { kind: 'full' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (first.length === 0) {
|
|
338
|
+
const suffix = safeInteger(second);
|
|
339
|
+
if (suffix === undefined || suffix <= 0 || size === 0) {
|
|
340
|
+
return { kind: 'full' };
|
|
341
|
+
}
|
|
342
|
+
return { kind: 'range', start: Math.max(0, size - suffix), end: size - 1 };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const start = safeInteger(first);
|
|
346
|
+
if (start === undefined) {
|
|
347
|
+
return { kind: 'full' };
|
|
348
|
+
}
|
|
349
|
+
if (start >= size) {
|
|
350
|
+
return { kind: 'unsatisfiable' };
|
|
351
|
+
}
|
|
352
|
+
if (second.length === 0) {
|
|
353
|
+
return { kind: 'range', start, end: size - 1 };
|
|
354
|
+
}
|
|
355
|
+
const requestedEnd = safeInteger(second);
|
|
356
|
+
if (requestedEnd === undefined || requestedEnd < start) {
|
|
357
|
+
return { kind: 'full' };
|
|
358
|
+
}
|
|
359
|
+
return { kind: 'range', start, end: Math.min(requestedEnd, size - 1) };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function safeInteger(value: string): number | undefined {
|
|
363
|
+
if (!/^\d+$/.test(value)) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const parsed = Number(value);
|
|
367
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function header(headers: Readonly<Record<string, string>>, wanted: string): string | undefined {
|
|
371
|
+
for (const name of Object.keys(headers)) {
|
|
372
|
+
if (name.toLowerCase() === wanted) {
|
|
373
|
+
return headers[name];
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function fileWindow(handle: FileHandle, start: number, length: number): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
380
|
+
let position = start;
|
|
381
|
+
let remaining = length;
|
|
382
|
+
let closed = false;
|
|
383
|
+
const close = async (): Promise<void> => {
|
|
384
|
+
if (!closed) {
|
|
385
|
+
closed = true;
|
|
386
|
+
await handle.close();
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
391
|
+
async pull(controller) {
|
|
392
|
+
try {
|
|
393
|
+
if (remaining === 0) {
|
|
394
|
+
await close();
|
|
395
|
+
controller.close();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const wanted = Math.min(64 * 1024, remaining);
|
|
399
|
+
const chunk = new Uint8Array(wanted);
|
|
400
|
+
const result = await handle.read(chunk, 0, wanted, position);
|
|
401
|
+
if (result.bytesRead === 0) {
|
|
402
|
+
throw new Error('static file ended before its measured length');
|
|
403
|
+
}
|
|
404
|
+
position += result.bytesRead;
|
|
405
|
+
remaining -= result.bytesRead;
|
|
406
|
+
controller.enqueue(chunk.slice(0, result.bytesRead));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
await close().catch(() => undefined);
|
|
409
|
+
controller.error(error);
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
cancel: close,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function notFound(): WebResponse {
|
|
417
|
+
return respond({ status: 404, headers: NOSNIFF });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isOrdinaryMiss(error: unknown): boolean {
|
|
421
|
+
const code = errorCode(error);
|
|
422
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function errorCode(error: unknown): string | undefined {
|
|
426
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
const code = Reflect.get(error, 'code');
|
|
430
|
+
return typeof code === 'string' ? code : undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function report(options: StaticOptions, error: unknown): void {
|
|
434
|
+
try {
|
|
435
|
+
options.onError(error);
|
|
436
|
+
} catch {
|
|
437
|
+
// A diagnostic sink cannot turn a uniform 404 into a rejected request.
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function closeQuietly(handle: FileHandle): Promise<void> {
|
|
442
|
+
await handle.close().catch(() => undefined);
|
|
443
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @zmdb/web — in-process HTTP testing over the app-owned graph and lifecycle.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileModule,
|
|
5
|
+
createApplication,
|
|
6
|
+
type ApplicationOptions,
|
|
7
|
+
type Container,
|
|
8
|
+
type ModuleClass,
|
|
9
|
+
type ProviderDef,
|
|
10
|
+
type Token,
|
|
11
|
+
} from '@zmdb/app';
|
|
12
|
+
import type { Observability } from '@zmdb/app/observability';
|
|
13
|
+
|
|
14
|
+
import { applicationControllersOf, withCompiledApplication } from '../app/bridge.js';
|
|
15
|
+
import { createRouter, type Router, type WebRequest, type WebResponse } from '../pipeline/index.js';
|
|
16
|
+
|
|
17
|
+
/** Options for `createTestApp`. */
|
|
18
|
+
export interface TestAppOptions {
|
|
19
|
+
readonly overrides?: readonly ProviderDef[];
|
|
20
|
+
readonly observability?: Observability;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A test application: drive requests in-process and resolve providers. */
|
|
24
|
+
export interface TestApp extends AsyncDisposable {
|
|
25
|
+
request(req: WebRequest): Promise<WebResponse>;
|
|
26
|
+
get<T>(token: Token<T>): T;
|
|
27
|
+
init(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compile one overridden graph, hand that exact graph to createApplication,
|
|
32
|
+
* and drive its controllers through the production HTTP router.
|
|
33
|
+
*/
|
|
34
|
+
export function createTestApp(rootModule: ModuleClass, options: TestAppOptions = {}): TestApp {
|
|
35
|
+
const compiled = compileModule(rootModule, options.overrides ?? []);
|
|
36
|
+
const applicationOptions: ApplicationOptions =
|
|
37
|
+
options.observability === undefined ? {} : { observability: options.observability };
|
|
38
|
+
const application = createApplication(rootModule, withCompiledApplication(applicationOptions, compiled));
|
|
39
|
+
const router: Router = createRouter(options.observability);
|
|
40
|
+
for (const binding of applicationControllersOf(application)) {
|
|
41
|
+
if (binding.kind === 'eager') {
|
|
42
|
+
router.register(binding.controller);
|
|
43
|
+
} else {
|
|
44
|
+
router.registerDeferred(binding.controller, binding.instance);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
request: req => router.handle(req),
|
|
50
|
+
get: <T>(token: Token<T>): T => resolveFrom(application.container, token),
|
|
51
|
+
init: application.init,
|
|
52
|
+
[Symbol.asyncDispose]: application[Symbol.asyncDispose],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveFrom<T>(container: Container, token: Token<T>): T {
|
|
57
|
+
return container.resolve(token);
|
|
58
|
+
}
|