@znight/http 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
|
@@ -0,0 +1,2180 @@
|
|
|
1
|
+
import { a as isMediaTypePattern, n as parseByteSize } from "./size-D8rsQcV0.mjs";
|
|
2
|
+
import { checkRoutePath, compareStrings, joinPaths, normalizePath, parseRoutePath } from "./path.mjs";
|
|
3
|
+
// src/compiler/ast.ts
|
|
4
|
+
/** `true` for an object shaped like an AST node. */
|
|
5
|
+
function isNode(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && typeof value.type === "string" && typeof value.start === "number";
|
|
7
|
+
}
|
|
8
|
+
/** The child node stored under `key`, when it is a node. */
|
|
9
|
+
function child(node, key) {
|
|
10
|
+
const value = node[key];
|
|
11
|
+
return isNode(value) ? value : void 0;
|
|
12
|
+
}
|
|
13
|
+
/** The child nodes stored under `key`, when it is an array. */
|
|
14
|
+
function children(node, key) {
|
|
15
|
+
const value = node[key];
|
|
16
|
+
return Array.isArray(value) ? value.filter(isNode) : [];
|
|
17
|
+
}
|
|
18
|
+
const WRAPPERS = /* @__PURE__ */ new Set([
|
|
19
|
+
"ParenthesizedExpression",
|
|
20
|
+
"TSAsExpression",
|
|
21
|
+
"TSSatisfiesExpression",
|
|
22
|
+
"TSNonNullExpression",
|
|
23
|
+
"TSTypeAssertion",
|
|
24
|
+
"TSInstantiationExpression"
|
|
25
|
+
]);
|
|
26
|
+
/** Removes parentheses and type-only wrappers (`x as T`, `x satisfies T`, `x!`, `<T>x`). */
|
|
27
|
+
function unwrap(node) {
|
|
28
|
+
let current = node;
|
|
29
|
+
while (WRAPPERS.has(current.type)) {
|
|
30
|
+
const inner = child(current, "expression");
|
|
31
|
+
if (!inner) return current;
|
|
32
|
+
current = inner;
|
|
33
|
+
}
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The default value expression of a method parameter (`page = 1` → `1`), found in the class
|
|
38
|
+
* declaration by the location of the method key.
|
|
39
|
+
*/
|
|
40
|
+
function defaultValueOf(cls, method, param) {
|
|
41
|
+
const classNode = cls.declaration.node;
|
|
42
|
+
if (!isNode(classNode)) return;
|
|
43
|
+
const body = child(classNode, "body");
|
|
44
|
+
if (!body) return;
|
|
45
|
+
for (const member of children(body, "body")) {
|
|
46
|
+
const key = child(member, "key");
|
|
47
|
+
if (!key || key.start !== method.location.start) continue;
|
|
48
|
+
const fn = child(member, "value");
|
|
49
|
+
if (!fn) return;
|
|
50
|
+
let pattern = children(fn, "params").filter((node) => !(node.type === "Identifier" && node["name"] === "this"))[param.index];
|
|
51
|
+
if (pattern?.type === "TSParameterProperty") pattern = child(pattern, "parameter");
|
|
52
|
+
return pattern?.type === "AssignmentPattern" ? child(pattern, "right") : void 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// src/compiler/coercion.ts
|
|
56
|
+
const DATE_IDENTITY = "globalThis#Date";
|
|
57
|
+
const STRING = {
|
|
58
|
+
kind: "primitive",
|
|
59
|
+
name: "string"
|
|
60
|
+
};
|
|
61
|
+
const NUMBER = {
|
|
62
|
+
kind: "primitive",
|
|
63
|
+
name: "number"
|
|
64
|
+
};
|
|
65
|
+
const BOOLEAN = {
|
|
66
|
+
kind: "primitive",
|
|
67
|
+
name: "boolean"
|
|
68
|
+
};
|
|
69
|
+
const BIGINT = {
|
|
70
|
+
kind: "primitive",
|
|
71
|
+
name: "bigint"
|
|
72
|
+
};
|
|
73
|
+
const DATE = {
|
|
74
|
+
kind: "reference",
|
|
75
|
+
identity: DATE_IDENTITY,
|
|
76
|
+
name: "Date",
|
|
77
|
+
typeArgs: []
|
|
78
|
+
};
|
|
79
|
+
/** Annotated or inferred type of a parameter. */
|
|
80
|
+
function parameterType(ctx, cls, method, param) {
|
|
81
|
+
let type = param.type;
|
|
82
|
+
if (param.type.kind === "opaque" && param.type.reason === "missing-annotation") type = param.hasDefault ? inferFromDefault(ctx, cls, method, param) : void 0;
|
|
83
|
+
const nullish = type !== void 0 && hasNullish(type);
|
|
84
|
+
return {
|
|
85
|
+
type,
|
|
86
|
+
optional: param.optional || param.hasDefault || nullish
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Plans the coercion of a raw value to `type`. `required` is set for route parameters, which are
|
|
91
|
+
* always present (no `coerce.optional`).
|
|
92
|
+
*/
|
|
93
|
+
function planCoercion(ctx, declared, param, required) {
|
|
94
|
+
const type = declared.type;
|
|
95
|
+
if (type === void 0) return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: `the type of parameter '${param.name}' is unknown`,
|
|
98
|
+
hints: [`Annotate the parameter: ${param.name}: string`]
|
|
99
|
+
};
|
|
100
|
+
const { type: base } = ctx.types.stripNullish(type);
|
|
101
|
+
if (isRaw(base)) return {
|
|
102
|
+
ok: true,
|
|
103
|
+
coercion: {
|
|
104
|
+
plan: { kind: "raw" },
|
|
105
|
+
cast: false
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const scalar = scalarPlan(ctx, base);
|
|
109
|
+
let plan;
|
|
110
|
+
let cast;
|
|
111
|
+
if ("reason" in scalar) {
|
|
112
|
+
if (base.kind !== "array") return failure(ctx, type, scalar.reason);
|
|
113
|
+
const item = scalarPlan(ctx, base.element);
|
|
114
|
+
if ("reason" in item) return failure(ctx, type, item.reason);
|
|
115
|
+
plan = {
|
|
116
|
+
kind: "array",
|
|
117
|
+
item: item.plan
|
|
118
|
+
};
|
|
119
|
+
cast = item.cast;
|
|
120
|
+
} else {
|
|
121
|
+
plan = scalar.plan;
|
|
122
|
+
cast = scalar.cast;
|
|
123
|
+
}
|
|
124
|
+
if (!required && declared.optional) {
|
|
125
|
+
plan = {
|
|
126
|
+
kind: "optional",
|
|
127
|
+
item: plan
|
|
128
|
+
};
|
|
129
|
+
cast ||= !(param.optional || param.hasDefault || acceptsUndefined(type));
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
coercion: {
|
|
134
|
+
plan,
|
|
135
|
+
cast
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** `unknown` and `any`: the raw value is passed as is. */
|
|
140
|
+
function isRaw(type) {
|
|
141
|
+
return type.kind === "primitive" && (type.name === "unknown" || type.name === "any");
|
|
142
|
+
}
|
|
143
|
+
function scalarPlan(ctx, type) {
|
|
144
|
+
switch (type.kind) {
|
|
145
|
+
case "primitive": {
|
|
146
|
+
const kind = primitiveCoercion(type.name);
|
|
147
|
+
return kind ? {
|
|
148
|
+
plan: { kind },
|
|
149
|
+
cast: false
|
|
150
|
+
} : { reason: `'${type.name}' values cannot be read from a request` };
|
|
151
|
+
}
|
|
152
|
+
case "literal": return typeof type.value === "bigint" ? { reason: "bigint literal types are not supported" } : {
|
|
153
|
+
plan: {
|
|
154
|
+
kind: "enum",
|
|
155
|
+
values: [type.value]
|
|
156
|
+
},
|
|
157
|
+
cast: false
|
|
158
|
+
};
|
|
159
|
+
case "union": {
|
|
160
|
+
const values = [];
|
|
161
|
+
for (const member of type.types) if (member.kind === "literal" && typeof member.value !== "bigint") values.push(member.value);
|
|
162
|
+
else if (member.kind === "primitive" && member.name === "boolean") values.push(false, true);
|
|
163
|
+
else return { reason: "only unions of literal types can be coerced" };
|
|
164
|
+
return {
|
|
165
|
+
plan: {
|
|
166
|
+
kind: "enum",
|
|
167
|
+
values
|
|
168
|
+
},
|
|
169
|
+
cast: false
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case "reference": {
|
|
173
|
+
if (type.identity === DATE_IDENTITY) return {
|
|
174
|
+
plan: { kind: "date" },
|
|
175
|
+
cast: false
|
|
176
|
+
};
|
|
177
|
+
const values = enumValues(ctx, type.identity);
|
|
178
|
+
if (values) return values.length === 0 ? { reason: `the enum ${type.name} has no member` } : {
|
|
179
|
+
plan: {
|
|
180
|
+
kind: "enum",
|
|
181
|
+
values
|
|
182
|
+
},
|
|
183
|
+
cast: true
|
|
184
|
+
};
|
|
185
|
+
return { reason: "objects cannot be read from a single request value" };
|
|
186
|
+
}
|
|
187
|
+
case "intersection": {
|
|
188
|
+
const primitives = type.types.filter((member) => member.kind === "primitive" || member.kind === "literal");
|
|
189
|
+
const [primitive] = primitives;
|
|
190
|
+
if (primitives.length === 1 && primitive !== void 0) {
|
|
191
|
+
const inner = scalarPlan(ctx, primitive);
|
|
192
|
+
return "reason" in inner ? inner : {
|
|
193
|
+
plan: inner.plan,
|
|
194
|
+
cast: true
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { reason: "only branded primitives (`string & { ... }`) can be coerced" };
|
|
198
|
+
}
|
|
199
|
+
case "array": return { reason: "nested arrays cannot be coerced" };
|
|
200
|
+
default: return { reason: "this type cannot be read from a request value" };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function primitiveCoercion(name) {
|
|
204
|
+
switch (name) {
|
|
205
|
+
case "string":
|
|
206
|
+
case "number":
|
|
207
|
+
case "boolean":
|
|
208
|
+
case "bigint": return name;
|
|
209
|
+
default: return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function failure(ctx, type, reason) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: `type '${ctx.types.format(type)}' cannot be coerced (${reason})`,
|
|
216
|
+
hints: ["Supported types: string, number, boolean, bigint, Date, literal unions, enums, branded primitives and arrays of them", "Or validate the value with a Standard Schema"]
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Member values of a TypeScript enum, `undefined` when `identity` is not an evaluable enum. */
|
|
220
|
+
function enumValues(ctx, identity) {
|
|
221
|
+
const symbol = ctx.project.lookupIdentity(identity);
|
|
222
|
+
if (symbol.kind !== "declaration" || symbol.declaration.kind !== "enum") return;
|
|
223
|
+
const declaration = symbol.declaration;
|
|
224
|
+
const node = declaration.node;
|
|
225
|
+
const id = isNode(node) ? child(node, "id") : void 0;
|
|
226
|
+
const file = ctx.project.getFile(declaration.file);
|
|
227
|
+
if (!id || !file) return;
|
|
228
|
+
const scope = {
|
|
229
|
+
file,
|
|
230
|
+
namespace: declaration.namespace
|
|
231
|
+
};
|
|
232
|
+
const result = ctx.project.evaluate(scope, id, { refs: "none" });
|
|
233
|
+
if (result.kind !== "value" || typeof result.value !== "object" || result.value === null) return;
|
|
234
|
+
return Object.values(result.value).filter((value) => typeof value === "string" || typeof value === "number");
|
|
235
|
+
}
|
|
236
|
+
function inferFromDefault(ctx, cls, method, param) {
|
|
237
|
+
const node = defaultValueOf(cls, method, param);
|
|
238
|
+
if (!node) return;
|
|
239
|
+
const inner = unwrap(node);
|
|
240
|
+
if (inner.type === "NewExpression") {
|
|
241
|
+
const callee = child(inner, "callee");
|
|
242
|
+
if (callee?.type === "Identifier") return ctx.project.resolveIdentifier(cls.file, callee, "value").identity?.identity === DATE_IDENTITY ? DATE : void 0;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const result = ctx.project.evaluate(cls.file, node, { refs: "none" });
|
|
246
|
+
return result.kind === "value" ? typeOfValue(result.value) : void 0;
|
|
247
|
+
}
|
|
248
|
+
function typeOfValue(value) {
|
|
249
|
+
switch (typeof value) {
|
|
250
|
+
case "string": return STRING;
|
|
251
|
+
case "number": return NUMBER;
|
|
252
|
+
case "boolean": return BOOLEAN;
|
|
253
|
+
case "bigint": return BIGINT;
|
|
254
|
+
default: {
|
|
255
|
+
if (!Array.isArray(value) || value.length === 0) return;
|
|
256
|
+
const items = value.map(typeOfValue);
|
|
257
|
+
const [first] = items;
|
|
258
|
+
if (first === void 0 || first.kind !== "primitive" || items.some((item) => item !== first)) return;
|
|
259
|
+
return {
|
|
260
|
+
kind: "array",
|
|
261
|
+
element: first
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function hasNullish(type) {
|
|
267
|
+
return type.kind === "union" && type.types.some(isNullish);
|
|
268
|
+
}
|
|
269
|
+
/** Whether a union accepts `undefined` (`T | undefined`, `T | void`). */
|
|
270
|
+
function acceptsUndefined(type) {
|
|
271
|
+
return type.kind === "union" && type.types.some((member) => member.kind === "primitive" && (member.name === "undefined" || member.name === "void"));
|
|
272
|
+
}
|
|
273
|
+
function isNullish(type) {
|
|
274
|
+
return type.kind === "primitive" && (type.name === "undefined" || type.name === "null" || type.name === "void");
|
|
275
|
+
}
|
|
276
|
+
// src/compiler/model.ts
|
|
277
|
+
/** Package name (prefix of every decorator identity). */
|
|
278
|
+
const HTTP_PACKAGE = "@znight/http";
|
|
279
|
+
/** Canonical identities of the decorators of `@znight/http`. */
|
|
280
|
+
const Ids = Object.freeze({
|
|
281
|
+
Controller: "@znight/http#Controller",
|
|
282
|
+
Param: "@znight/http#Param",
|
|
283
|
+
Query: "@znight/http#Query",
|
|
284
|
+
Body: "@znight/http#Body",
|
|
285
|
+
Headers: "@znight/http#Headers",
|
|
286
|
+
Req: "@znight/http#Req",
|
|
287
|
+
Res: "@znight/http#Res",
|
|
288
|
+
Ctx: "@znight/http#Ctx",
|
|
289
|
+
HttpCode: "@znight/http#HttpCode",
|
|
290
|
+
Header: "@znight/http#Header",
|
|
291
|
+
UseGuard: "@znight/http#UseGuard",
|
|
292
|
+
UseInterceptor: "@znight/http#UseInterceptor",
|
|
293
|
+
Validate: "@znight/http#Validate",
|
|
294
|
+
UploadedFile: "@znight/http#UploadedFile",
|
|
295
|
+
UploadedFiles: "@znight/http#UploadedFiles",
|
|
296
|
+
BodyLimit: "@znight/http#BodyLimit",
|
|
297
|
+
Cookie: "@znight/http#Cookie",
|
|
298
|
+
SignedCookie: "@znight/http#SignedCookie",
|
|
299
|
+
Sse: "@znight/http#Sse",
|
|
300
|
+
createRouteDecorator: "@znight/http#createRouteDecorator",
|
|
301
|
+
RouteDecorator: "@znight/http#RouteDecorator"
|
|
302
|
+
});
|
|
303
|
+
/** Route method decorators → HTTP method (`@Sse` declares a `GET` route). */
|
|
304
|
+
const ROUTE_METHODS = /* @__PURE__ */ new Map([
|
|
305
|
+
["@znight/http#Get", "GET"],
|
|
306
|
+
["@znight/http#Post", "POST"],
|
|
307
|
+
["@znight/http#Put", "PUT"],
|
|
308
|
+
["@znight/http#Patch", "PATCH"],
|
|
309
|
+
["@znight/http#Delete", "DELETE"],
|
|
310
|
+
["@znight/http#Head", "HEAD"],
|
|
311
|
+
["@znight/http#Options", "OPTIONS"],
|
|
312
|
+
["@znight/http#All", "ALL"],
|
|
313
|
+
["@znight/http#Sse", "GET"]
|
|
314
|
+
]);
|
|
315
|
+
/** Parameter decorators → parameter source. */
|
|
316
|
+
const PARAM_SOURCES = /* @__PURE__ */ new Map([
|
|
317
|
+
[Ids.Param, "param"],
|
|
318
|
+
[Ids.Query, "query"],
|
|
319
|
+
[Ids.Body, "body"],
|
|
320
|
+
[Ids.Headers, "headers"],
|
|
321
|
+
[Ids.Req, "request"],
|
|
322
|
+
[Ids.Res, "response"],
|
|
323
|
+
[Ids.Ctx, "context"],
|
|
324
|
+
[Ids.UploadedFile, "file"],
|
|
325
|
+
[Ids.UploadedFiles, "files"],
|
|
326
|
+
[Ids.Cookie, "cookie"],
|
|
327
|
+
[Ids.SignedCookie, "signedCookie"]
|
|
328
|
+
]);
|
|
329
|
+
/** Error codes used by the extension (see `ErrorCode` in `@znight/core`). */
|
|
330
|
+
const Codes = Object.freeze({
|
|
331
|
+
MissingProvider: "ZNIGHT001",
|
|
332
|
+
InvalidDecorator: "ZNIGHT005",
|
|
333
|
+
InvalidRoute: "ZNIGHT007",
|
|
334
|
+
UnvalidatedInput: "ZNIGHT017",
|
|
335
|
+
MissingRouteDecision: "ZNIGHT023"
|
|
336
|
+
});
|
|
337
|
+
/**
|
|
338
|
+
* Grammar of an explicit route decorator group name (same as the runtime `createRouteDecorator`).
|
|
339
|
+
* A decorator without `group` is its own group, named after its key (any non-empty key).
|
|
340
|
+
*/
|
|
341
|
+
const GROUP_NAME = /^[A-Za-z][\w.-]*$/;
|
|
342
|
+
// src/compiler/route-decorators.ts
|
|
343
|
+
const OTHER = Object.freeze({ kind: "other" });
|
|
344
|
+
/** Resolutions per analysis context (one context per compile). */
|
|
345
|
+
const caches = /* @__PURE__ */ new WeakMap();
|
|
346
|
+
/** Resolves a decorator identity to a route decorator definition (memoized per compile). */
|
|
347
|
+
function resolveRouteDecorator(ctx, identity) {
|
|
348
|
+
let cache = caches.get(ctx);
|
|
349
|
+
if (!cache) {
|
|
350
|
+
cache = /* @__PURE__ */ new Map();
|
|
351
|
+
caches.set(ctx, cache);
|
|
352
|
+
}
|
|
353
|
+
let resolution = cache.get(identity);
|
|
354
|
+
if (!resolution) {
|
|
355
|
+
resolution = resolve(ctx, identity);
|
|
356
|
+
cache.set(identity, resolution);
|
|
357
|
+
}
|
|
358
|
+
return resolution;
|
|
359
|
+
}
|
|
360
|
+
function resolve(ctx, identity) {
|
|
361
|
+
if (identity.startsWith("globalThis#")) return OTHER;
|
|
362
|
+
const symbol = ctx.project.lookupIdentity(identity);
|
|
363
|
+
if (symbol.kind !== "declaration") return OTHER;
|
|
364
|
+
const declaration = symbol.declarations.find((candidate) => candidate.kind === "const");
|
|
365
|
+
const node = declaration?.node;
|
|
366
|
+
const file = declaration ? ctx.project.getFile(declaration.file) : void 0;
|
|
367
|
+
if (!declaration || !isNode(node) || !file) return OTHER;
|
|
368
|
+
const scope = {
|
|
369
|
+
file,
|
|
370
|
+
namespace: declaration.namespace
|
|
371
|
+
};
|
|
372
|
+
const init = child(node, "init");
|
|
373
|
+
if (!init) {
|
|
374
|
+
const annotation = child(child(node, "id") ?? node, "typeAnnotation");
|
|
375
|
+
const type = annotation ? ctx.project.resolveType(scope, annotation) : void 0;
|
|
376
|
+
if (type?.kind === "reference" && type.identity === Ids.RouteDecorator) return {
|
|
377
|
+
kind: "invalid",
|
|
378
|
+
message: `the key of the route decorator ${declaration.name} cannot be read: it is declared without initializer`,
|
|
379
|
+
hints: ["Route decorators must be declared in analysed sources: `export const X = createRouteDecorator(key)`", "For a provider library, publish its sources (\"znight\": { \"library\": \"source\" })"]
|
|
380
|
+
};
|
|
381
|
+
return OTHER;
|
|
382
|
+
}
|
|
383
|
+
const call = unwrap(init);
|
|
384
|
+
const callee = call.type === "CallExpression" ? child(call, "callee") : void 0;
|
|
385
|
+
if (!callee || !isEntityName(unwrap(callee))) return OTHER;
|
|
386
|
+
if (ctx.project.resolveIdentifier(scope, unwrap(callee), "value").identity?.identity !== Ids.createRouteDecorator) return OTHER;
|
|
387
|
+
const definition = file.location(declaration.span);
|
|
388
|
+
const [keyNode, optionsNode, ...extra] = children(call, "arguments");
|
|
389
|
+
if (!keyNode || keyNode.type === "SpreadElement") return {
|
|
390
|
+
kind: "invalid",
|
|
391
|
+
message: `createRouteDecorator() requires a key (${declaration.name})`,
|
|
392
|
+
hints: [`export const ${declaration.name} = createRouteDecorator('${declaration.name.toLowerCase()}')`],
|
|
393
|
+
definition
|
|
394
|
+
};
|
|
395
|
+
const key = ctx.project.evaluate(scope, keyNode, { refs: "none" });
|
|
396
|
+
if (key.kind !== "value" || typeof key.value !== "string" || key.value === "") return {
|
|
397
|
+
kind: "invalid",
|
|
398
|
+
message: `the key of the route decorator ${declaration.name} must be a statically evaluable non-empty string`,
|
|
399
|
+
hints: ["Use a string literal or a `const` string: createRouteDecorator('roles')"],
|
|
400
|
+
definition
|
|
401
|
+
};
|
|
402
|
+
const optionsHints = [`export const ${declaration.name} = createRouteDecorator('${key.value}', { group: 'access', required: true })`];
|
|
403
|
+
if (extra.length > 0 || optionsNode?.type === "SpreadElement") return {
|
|
404
|
+
kind: "invalid",
|
|
405
|
+
message: `createRouteDecorator() takes a key and an optional { group?, required? } object (${declaration.name})`,
|
|
406
|
+
hints: optionsHints,
|
|
407
|
+
definition
|
|
408
|
+
};
|
|
409
|
+
let options;
|
|
410
|
+
if (optionsNode !== void 0) {
|
|
411
|
+
const evaluated = ctx.project.evaluate(scope, optionsNode, { refs: "none" });
|
|
412
|
+
if (evaluated.kind !== "value") return {
|
|
413
|
+
kind: "invalid",
|
|
414
|
+
message: `the options of the route decorator ${declaration.name} must be statically evaluable (literals and \`const\` references)`,
|
|
415
|
+
hints: optionsHints,
|
|
416
|
+
definition
|
|
417
|
+
};
|
|
418
|
+
options = evaluated.value;
|
|
419
|
+
}
|
|
420
|
+
const parsed = parseRouteDecoratorOptions(key.value, options);
|
|
421
|
+
if ("error" in parsed) return {
|
|
422
|
+
kind: "invalid",
|
|
423
|
+
message: `the options of the route decorator ${declaration.name} ${parsed.error}`,
|
|
424
|
+
hints: optionsHints,
|
|
425
|
+
definition
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
kind: "route",
|
|
429
|
+
key: key.value,
|
|
430
|
+
...parsed,
|
|
431
|
+
name: declaration.name,
|
|
432
|
+
definition
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The group and `required` of a route decorator from its evaluated options (`undefined`: the
|
|
437
|
+
* defaults), or why the options are invalid.
|
|
438
|
+
*/
|
|
439
|
+
function parseRouteDecoratorOptions(key, options) {
|
|
440
|
+
if (options === void 0) return {
|
|
441
|
+
group: key,
|
|
442
|
+
required: false
|
|
443
|
+
};
|
|
444
|
+
if (!isPlainObject$1(options)) return { error: "must be an object { group?, required? }" };
|
|
445
|
+
const unknown = Object.keys(options).find((name) => name !== "group" && name !== "required");
|
|
446
|
+
if (unknown !== void 0) return { error: `have an unknown property "${unknown}" (expected group and required)` };
|
|
447
|
+
const group = options["group"] ?? key;
|
|
448
|
+
if (typeof group !== "string" || group !== key && !GROUP_NAME.test(group)) return { error: `have an invalid group ${typeof group === "object" && group !== null ? "a reference" : JSON.stringify(group)}: a letter followed by letters, digits, _ . or -` };
|
|
449
|
+
const required = options["required"] ?? false;
|
|
450
|
+
if (typeof required !== "boolean") return { error: "have a non-boolean \"required\"" };
|
|
451
|
+
return {
|
|
452
|
+
group,
|
|
453
|
+
required
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function isPlainObject$1(value) {
|
|
457
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
458
|
+
const prototype = Object.getPrototypeOf(value);
|
|
459
|
+
return prototype === Object.prototype || prototype === null;
|
|
460
|
+
}
|
|
461
|
+
function isEntityName(node) {
|
|
462
|
+
if (node.type === "Identifier") return true;
|
|
463
|
+
if (node.type === "MemberExpression" && node["computed"] !== true) {
|
|
464
|
+
const object = child(node, "object");
|
|
465
|
+
return child(node, "property")?.type === "Identifier" && object !== void 0 && isEntityName(object);
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Metadata entries of the route decorators applied to a class or a method (source order).
|
|
471
|
+
* `target` names the class or the handler (`BookController`, `BookController.remove`). Problems are
|
|
472
|
+
* reported as ZNIGHT005: two decisions of one group on the same target included.
|
|
473
|
+
*/
|
|
474
|
+
function routeMeta(ctx, cls, decorators, target) {
|
|
475
|
+
const entries = [];
|
|
476
|
+
for (const decorator of decorators) {
|
|
477
|
+
if (decorator.owner !== void 0 || decorator.identity === void 0) continue;
|
|
478
|
+
const resolution = resolveRouteDecorator(ctx, decorator.identity);
|
|
479
|
+
if (resolution.kind === "other") continue;
|
|
480
|
+
if (resolution.kind === "invalid") {
|
|
481
|
+
report(ctx, decorator, `Invalid route decorator @${decorator.name}: ${resolution.message}`, resolution.hints, resolution.definition === void 0 ? void 0 : {
|
|
482
|
+
message: "The route decorator is defined here",
|
|
483
|
+
location: resolution.definition
|
|
484
|
+
});
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const value = metaValue(ctx, cls, decorator);
|
|
488
|
+
if (value === INVALID) continue;
|
|
489
|
+
const previous = entries.find((entry) => entry.group === resolution.group);
|
|
490
|
+
if (previous) {
|
|
491
|
+
if (previous.key === resolution.key && resolution.group === resolution.key) report(ctx, decorator, `@${decorator.name} sets the route metadata '${resolution.key}', already set on the same target`, ["Apply each route decorator once per controller or route"], {
|
|
492
|
+
message: "First applied here",
|
|
493
|
+
location: previous.location
|
|
494
|
+
});
|
|
495
|
+
else {
|
|
496
|
+
const alternatives = previous.decorator === resolution.name ? `@${resolution.name} once` : `one of @${previous.decorator}, @${resolution.name}`;
|
|
497
|
+
report(ctx, decorator, `@${resolution.name} sets the "${resolution.group}" decision of ${target}, already set by @${previous.decorator} on the same target`, [`Keep ${alternatives}: the decorators of the "${resolution.group}" group are alternatives`], {
|
|
498
|
+
message: "First applied here",
|
|
499
|
+
location: previous.location
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
entries.push({
|
|
505
|
+
key: resolution.key,
|
|
506
|
+
value,
|
|
507
|
+
location: decorator.location,
|
|
508
|
+
group: resolution.group,
|
|
509
|
+
required: resolution.required,
|
|
510
|
+
decorator: resolution.name,
|
|
511
|
+
identity: decorator.identity,
|
|
512
|
+
definition: resolution.definition
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return entries;
|
|
516
|
+
}
|
|
517
|
+
/** `true` when the decorator is a route metadata decorator. */
|
|
518
|
+
function isRouteMetaDecorator(ctx, decorator) {
|
|
519
|
+
return decorator.owner === void 0 && decorator.identity !== void 0 && resolveRouteDecorator(ctx, decorator.identity).kind !== "other";
|
|
520
|
+
}
|
|
521
|
+
const INVALID = Symbol("invalid");
|
|
522
|
+
function metaValue(ctx, cls, decorator) {
|
|
523
|
+
if (!decorator.called) {
|
|
524
|
+
report(ctx, decorator, `@${decorator.name} must be called: @${decorator.name}()`, [`Write @${decorator.name}(${decorator.args.length > 0 ? "..." : ""})`]);
|
|
525
|
+
return INVALID;
|
|
526
|
+
}
|
|
527
|
+
const [arg, ...extra] = decorator.args;
|
|
528
|
+
if (arg === void 0) return true;
|
|
529
|
+
if (extra.length > 0) {
|
|
530
|
+
report(ctx, decorator, `@${decorator.name} takes at most one argument`, ["Pass a single value (an array or an object)"]);
|
|
531
|
+
return INVALID;
|
|
532
|
+
}
|
|
533
|
+
if (arg.text.startsWith("...")) {
|
|
534
|
+
report(ctx, decorator, `@${decorator.name} does not accept spread arguments`, ["Pass the value directly"]);
|
|
535
|
+
return INVALID;
|
|
536
|
+
}
|
|
537
|
+
const evaluation = ctx.project.evaluate(cls.file, arg.node, { refs: "nested" });
|
|
538
|
+
switch (evaluation.kind) {
|
|
539
|
+
case "value": return checkRefs(ctx, decorator, evaluation.value) ? evaluation.value : INVALID;
|
|
540
|
+
case "ref": return checkRefs(ctx, decorator, evaluation.ref) ? evaluation.ref : INVALID;
|
|
541
|
+
case "error":
|
|
542
|
+
ctx.report({
|
|
543
|
+
code: Codes.InvalidDecorator,
|
|
544
|
+
severity: "error",
|
|
545
|
+
message: `Invalid argument of @${decorator.name}: ${evaluation.error.message}`,
|
|
546
|
+
location: evaluation.error.location,
|
|
547
|
+
hints: [evaluation.error.hint ?? "Extract the value to `export const X = ...` and reference it"]
|
|
548
|
+
});
|
|
549
|
+
return INVALID;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/** Checks that every reference of a value can be imported by the generated module. */
|
|
553
|
+
function checkRefs(ctx, decorator, value) {
|
|
554
|
+
let ok = true;
|
|
555
|
+
const visit = (current) => {
|
|
556
|
+
if (isValueRef(current)) {
|
|
557
|
+
const route = ctx.project.importRoute(current.symbol);
|
|
558
|
+
if (!route.ok) {
|
|
559
|
+
ok = false;
|
|
560
|
+
ctx.report({
|
|
561
|
+
code: Codes.InvalidDecorator,
|
|
562
|
+
severity: "error",
|
|
563
|
+
message: `Invalid argument of @${decorator.name}: '${current.text}' cannot be imported by the generated module (${route.error.message})`,
|
|
564
|
+
location: current.location,
|
|
565
|
+
hints: route.error.hints.length > 0 ? [...route.error.hints] : ["Export it from its module"]
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (Array.isArray(current)) for (const item of current) visit(item);
|
|
571
|
+
else if (typeof current === "object" && current !== null) for (const item of Object.values(current)) visit(item);
|
|
572
|
+
};
|
|
573
|
+
visit(value);
|
|
574
|
+
return ok;
|
|
575
|
+
}
|
|
576
|
+
/** `true` for a {@link ValueRef} (the only non-plain object a static value can hold). */
|
|
577
|
+
function isValueRef(value) {
|
|
578
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
579
|
+
const prototype = Object.getPrototypeOf(value);
|
|
580
|
+
return prototype !== Object.prototype && prototype !== null && value.kind === "ref";
|
|
581
|
+
}
|
|
582
|
+
function report(ctx, decorator, message, hints, related) {
|
|
583
|
+
ctx.report({
|
|
584
|
+
code: Codes.InvalidDecorator,
|
|
585
|
+
severity: "error",
|
|
586
|
+
message,
|
|
587
|
+
location: decorator.location,
|
|
588
|
+
hints,
|
|
589
|
+
...related?.location ? { related: [{
|
|
590
|
+
message: related.message,
|
|
591
|
+
location: related.location
|
|
592
|
+
}] } : {}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
// src/compiler/analyze.ts
|
|
596
|
+
/** Name of the extension (owner of the decorators in `DecoratorInfo.owner`). */
|
|
597
|
+
const EXTENSION_NAME = "http";
|
|
598
|
+
const SCOPES = [
|
|
599
|
+
"singleton",
|
|
600
|
+
"request",
|
|
601
|
+
"transient"
|
|
602
|
+
];
|
|
603
|
+
/** RFC 9110 token (header names). */
|
|
604
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
605
|
+
/** Types an upload parameter may have (`File`, `Blob`, arrays of them for `@UploadedFiles`); `true`: cast needed. */
|
|
606
|
+
const FILE_TYPES = /* @__PURE__ */ new Map([
|
|
607
|
+
["globalThis#File", false],
|
|
608
|
+
["globalThis#Blob", false],
|
|
609
|
+
["node:buffer#File", true],
|
|
610
|
+
["node:buffer#Blob", true]
|
|
611
|
+
]);
|
|
612
|
+
/** Methods whose requests usually have no body (upload parameters there are a warning). */
|
|
613
|
+
const BODYLESS_METHODS = /* @__PURE__ */ new Set([
|
|
614
|
+
"GET",
|
|
615
|
+
"HEAD",
|
|
616
|
+
"DELETE",
|
|
617
|
+
"OPTIONS"
|
|
618
|
+
]);
|
|
619
|
+
const UPLOAD_OPTIONS = [
|
|
620
|
+
"required",
|
|
621
|
+
"maxSize",
|
|
622
|
+
"maxFiles",
|
|
623
|
+
"mimeTypes"
|
|
624
|
+
];
|
|
625
|
+
/** Smallest `@Sse` heartbeat and `maxDuration` (ms). */
|
|
626
|
+
const MIN_SSE_INTERVAL_MS = 1e3;
|
|
627
|
+
/** Longest `@Sse` heartbeat and `maxDuration` (ms): the longest `setTimeout` delay. */
|
|
628
|
+
const MAX_SSE_TIMER_MS = 2147483647;
|
|
629
|
+
/** Primitive return types an `@Sse` handler cannot return (`any`, `unknown`, `object`, `never` are accepted). */
|
|
630
|
+
const NOT_SSE_SOURCES = /* @__PURE__ */ new Set([
|
|
631
|
+
"string",
|
|
632
|
+
"number",
|
|
633
|
+
"boolean",
|
|
634
|
+
"bigint",
|
|
635
|
+
"symbol",
|
|
636
|
+
"undefined",
|
|
637
|
+
"null",
|
|
638
|
+
"void"
|
|
639
|
+
]);
|
|
640
|
+
/** Values that are fine without schema (`@Body() body: string`, `@Body() bytes: Uint8Array`...). */
|
|
641
|
+
const UNVALIDATED_REFERENCES = /* @__PURE__ */ new Set([
|
|
642
|
+
"globalThis#Uint8Array",
|
|
643
|
+
"globalThis#ArrayBuffer",
|
|
644
|
+
"globalThis#Buffer",
|
|
645
|
+
"globalThis#Blob",
|
|
646
|
+
"globalThis#FormData",
|
|
647
|
+
"node:buffer#Buffer",
|
|
648
|
+
"@znight/http#QueryRecord"
|
|
649
|
+
]);
|
|
650
|
+
/** Analyses a class carrying at least one `@znight/http` decorator. */
|
|
651
|
+
function analyzeHttpClass(cls, ctx) {
|
|
652
|
+
const controllers = cls.decorators.filter((decorator) => decorator.identity === Ids.Controller);
|
|
653
|
+
const [controller] = controllers;
|
|
654
|
+
if (controller === void 0) {
|
|
655
|
+
reportOutsideController(cls, ctx);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
for (const duplicate of controllers.slice(1)) error(ctx, duplicate.location, `@Controller is applied several times to ${cls.displayName}`, ["Keep a single @Controller()"]);
|
|
659
|
+
const options = controllerOptions(ctx, controller);
|
|
660
|
+
const token = ctx.registerProvider(cls, { scope: options.scope });
|
|
661
|
+
const basePath = normalizePath(options.path);
|
|
662
|
+
const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, "guard");
|
|
663
|
+
const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, "interceptor");
|
|
664
|
+
const meta = routeMeta(ctx, cls, cls.decorators, cls.displayName);
|
|
665
|
+
const bodyLimit = bodyLimitOf(ctx, cls.decorators);
|
|
666
|
+
const routes = [];
|
|
667
|
+
for (const method of cls.methods) routes.push(...analyzeMethod(ctx, cls, method, basePath, bodyLimit));
|
|
668
|
+
ctx.store({
|
|
669
|
+
token,
|
|
670
|
+
identity: cls.identity,
|
|
671
|
+
displayName: cls.displayName,
|
|
672
|
+
rawBasePath: options.path,
|
|
673
|
+
basePath,
|
|
674
|
+
guards,
|
|
675
|
+
interceptors,
|
|
676
|
+
meta,
|
|
677
|
+
routes,
|
|
678
|
+
location: controller.location,
|
|
679
|
+
pathLocation: options.pathLocation
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
function controllerOptions(ctx, decorator) {
|
|
683
|
+
const [arg] = decorator.args;
|
|
684
|
+
const defaults = {
|
|
685
|
+
path: "",
|
|
686
|
+
pathLocation: decorator.location,
|
|
687
|
+
scope: "singleton"
|
|
688
|
+
};
|
|
689
|
+
if (arg === void 0 || !arg.evaluated) return defaults;
|
|
690
|
+
const value = arg.value;
|
|
691
|
+
if (typeof value === "string") return {
|
|
692
|
+
...defaults,
|
|
693
|
+
path: value,
|
|
694
|
+
pathLocation: arg.location
|
|
695
|
+
};
|
|
696
|
+
if (!isPlainObject(value)) {
|
|
697
|
+
error(ctx, arg.location, `@Controller expects a path or { path?, scope? }, got ${describe(value)}`, ["@Controller('/users') or @Controller({ path: '/users', scope: 'request' })"]);
|
|
698
|
+
return defaults;
|
|
699
|
+
}
|
|
700
|
+
let { path, scope } = defaults;
|
|
701
|
+
for (const [key, option] of Object.entries(value)) if (key === "path") {
|
|
702
|
+
if (typeof option === "string") path = option;
|
|
703
|
+
else if (option !== void 0) error(ctx, arg.location, `@Controller: "path" must be a string, got ${describe(option)}`);
|
|
704
|
+
} else if (key === "scope") {
|
|
705
|
+
if (SCOPES.includes(option)) scope = option;
|
|
706
|
+
else if (option !== void 0) error(ctx, arg.location, `@Controller: invalid scope ${describe(option)}`, [`Use one of ${SCOPES.map((candidate) => `'${candidate}'`).join(", ")}`]);
|
|
707
|
+
} else error(ctx, arg.location, `@Controller: unknown option "${key}"`, ["Options: { path?, scope? }"]);
|
|
708
|
+
return {
|
|
709
|
+
path,
|
|
710
|
+
pathLocation: arg.location,
|
|
711
|
+
scope
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* HTTP decorators on a class that is not a controller have no effect. On a class another extension
|
|
716
|
+
* owns (a `@Consumer` of `@znight/messaging`...), adding `@Controller()` is no fix: a class is the
|
|
717
|
+
* provider of a single extension.
|
|
718
|
+
*/
|
|
719
|
+
function reportOutsideController(cls, ctx) {
|
|
720
|
+
const owner = foreignOwner(cls);
|
|
721
|
+
const hint = owner === void 0 ? [`Add @Controller() to ${cls.displayName} (routes are not inherited from base classes)`] : [`${cls.displayName} is a @${owner.name} class of '${owner.module}': HTTP decorators only apply to @Controller() classes`];
|
|
722
|
+
const decorators = [...cls.decorators];
|
|
723
|
+
for (const method of cls.methods) {
|
|
724
|
+
decorators.push(...method.decorators);
|
|
725
|
+
const handler = owner === void 0 ? void 0 : method.decorators.find((decorator) => decorator.owner === owner.decorator.owner);
|
|
726
|
+
if (owner !== void 0 && handler !== void 0) {
|
|
727
|
+
reportHandlerParams(ctx, cls, method, owner, handler);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
for (const param of method.params) decorators.push(...unique(param.decorators));
|
|
731
|
+
}
|
|
732
|
+
for (const decorator of decorators) if (decorator.owner === "http" && validTarget(decorator)) error(ctx, decorator.location, `@${decorator.name} has no effect: ${cls.displayName} is not a controller`, hint);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* HTTP decorators on the parameters of a handler of the extension owning the class (an `@OnMessage`
|
|
736
|
+
* handler...). That extension reads its handlers' parameters and reports those it cannot read
|
|
737
|
+
* (`Headers` imported from `@znight/http` instead of `@znight/messaging`...): an HTTP decorator is
|
|
738
|
+
* only reported here when the parameter is read through a decorator of that extension, or is
|
|
739
|
+
* optional (left undefined without a word).
|
|
740
|
+
*/
|
|
741
|
+
function reportHandlerParams(ctx, cls, method, owner, handler) {
|
|
742
|
+
for (const param of method.params) {
|
|
743
|
+
if (!param.decorators.some((decorator) => decorator.owner === owner.decorator.owner) && !param.optional && !param.hasDefault && !param.rest) continue;
|
|
744
|
+
for (const decorator of unique(param.decorators)) if (decorator.owner === "http" && validTarget(decorator)) error(ctx, decorator.location, `@${decorator.name} has no effect on parameter '${param.name}' of ${cls.displayName}.${method.name}: it is an @${handler.name} handler of '${owner.module}'`, [`Use the parameter decorators of '${owner.module}', or remove it`]);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function qualify(decorator) {
|
|
748
|
+
const identity = decorator.identity;
|
|
749
|
+
const separator = identity?.lastIndexOf("#") ?? -1;
|
|
750
|
+
if (identity === void 0 || separator <= 0) return;
|
|
751
|
+
return {
|
|
752
|
+
decorator,
|
|
753
|
+
module: identity.slice(0, separator),
|
|
754
|
+
name: identity.slice(separator + 1)
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/** The class decorator through which another extension owns the class (`@Consumer`...). */
|
|
758
|
+
function foreignOwner(cls) {
|
|
759
|
+
for (const decorator of cls.decorators) {
|
|
760
|
+
const owner = decorator.owner;
|
|
761
|
+
if (owner === void 0 || owner === "http" || owner === "core" || owner === "manifest") continue;
|
|
762
|
+
const qualified = qualify(decorator);
|
|
763
|
+
if (qualified !== void 0) return qualified;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* A parameter decorator named like an HTTP one but imported from another package (`Headers` and
|
|
768
|
+
* `Ctx` of `@znight/messaging`, typically auto-imported).
|
|
769
|
+
*/
|
|
770
|
+
function foreignParameterDecorator(param) {
|
|
771
|
+
for (const decorator of unique(param.decorators)) {
|
|
772
|
+
const qualified = qualify(decorator);
|
|
773
|
+
if (qualified !== void 0 && qualified.module !== "@znight/http" && PARAM_SOURCES.has(`@znight/http#${qualified.name}`)) return qualified;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function analyzeMethod(ctx, cls, method, basePath, controllerBodyLimit) {
|
|
777
|
+
const where = `${cls.displayName}.${method.name}`;
|
|
778
|
+
let routeDecorators = method.decorators.filter((decorator) => ROUTE_METHODS.has(decorator.identity ?? ""));
|
|
779
|
+
if (routeDecorators.length === 0) {
|
|
780
|
+
reportNotARoute(ctx, method, where);
|
|
781
|
+
return [];
|
|
782
|
+
}
|
|
783
|
+
if (!checkHandler(ctx, method, where, routeDecorators[0])) return [];
|
|
784
|
+
const sseDecorator = routeDecorators.find((decorator) => decorator.identity === Ids.Sse);
|
|
785
|
+
if (sseDecorator !== void 0) {
|
|
786
|
+
for (const other of routeDecorators) {
|
|
787
|
+
if (other === sseDecorator) continue;
|
|
788
|
+
error(ctx, other.location, other.identity === Ids.Sse ? `@Sse is applied several times to ${where}` : `@${other.name} cannot be combined with @Sse on ${where}`, ["One handler per event stream: declare other routes on other methods (HttpResponse.sse() serves an event stream from any route)"]);
|
|
789
|
+
}
|
|
790
|
+
routeDecorators = [sseDecorator];
|
|
791
|
+
}
|
|
792
|
+
const decorators = method.decorators;
|
|
793
|
+
const bodyValidation = validateSchema(ctx, decorators);
|
|
794
|
+
const { params, complete } = analyzeParams(ctx, cls, method, where, bodyValidation);
|
|
795
|
+
let status = httpCode(ctx, decorators);
|
|
796
|
+
let headers = responseHeaders(ctx, decorators);
|
|
797
|
+
const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, "guard");
|
|
798
|
+
const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, "interceptor");
|
|
799
|
+
const meta = routeMeta(ctx, cls, decorators, where);
|
|
800
|
+
const bodySchema = bodyValidation !== void 0 && !params.some((param) => param.source === "body") ? bodyValidation.schema : void 0;
|
|
801
|
+
let rawResponse = params.some((param) => param.source === "response");
|
|
802
|
+
const bodyLimit = bodyLimitOf(ctx, decorators) ?? controllerBodyLimit;
|
|
803
|
+
const uploads = checkUploads(ctx, params, where);
|
|
804
|
+
if (sseDecorator !== void 0) {
|
|
805
|
+
checkSseHandler(ctx, method, params, where, bodyValidation);
|
|
806
|
+
status = void 0;
|
|
807
|
+
headers = headers.filter(([name]) => name !== "content-type");
|
|
808
|
+
rawResponse = false;
|
|
809
|
+
}
|
|
810
|
+
const routes = [];
|
|
811
|
+
for (const decorator of routeDecorators) {
|
|
812
|
+
const httpMethod = ROUTE_METHODS.get(decorator.identity ?? "");
|
|
813
|
+
const target = decorator === sseDecorator ? sseArgs(ctx, decorator) : routePath(ctx, decorator);
|
|
814
|
+
if (target === void 0) continue;
|
|
815
|
+
const { rawPath, arg } = target;
|
|
816
|
+
const path = normalizePath(rawPath);
|
|
817
|
+
if (uploads && BODYLESS_METHODS.has(httpMethod)) ctx.report({
|
|
818
|
+
code: Codes.InvalidDecorator,
|
|
819
|
+
severity: "warning",
|
|
820
|
+
message: `${where} reads uploaded files but is a ${httpMethod} route: ${httpMethod} requests usually have no body`,
|
|
821
|
+
location: decorator.location,
|
|
822
|
+
hints: ["Upload files with @Post(), @Put() or @Patch()"]
|
|
823
|
+
});
|
|
824
|
+
routes.push({
|
|
825
|
+
method: httpMethod,
|
|
826
|
+
rawPath,
|
|
827
|
+
path,
|
|
828
|
+
fullPath: joinPaths(basePath, path),
|
|
829
|
+
handler: method.name,
|
|
830
|
+
status,
|
|
831
|
+
headers,
|
|
832
|
+
meta,
|
|
833
|
+
guards,
|
|
834
|
+
interceptors,
|
|
835
|
+
params,
|
|
836
|
+
complete,
|
|
837
|
+
bodySchema,
|
|
838
|
+
bodyLimit,
|
|
839
|
+
rawResponse,
|
|
840
|
+
...target.sse === void 0 ? {} : { sse: target.sse },
|
|
841
|
+
location: decorator.location,
|
|
842
|
+
pathLocation: arg?.location ?? decorator.location
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return routes;
|
|
846
|
+
}
|
|
847
|
+
/** The path argument of a route decorator (`undefined` after an error). */
|
|
848
|
+
function routePath(ctx, decorator) {
|
|
849
|
+
const [arg] = decorator.args;
|
|
850
|
+
if (arg === void 0) return {
|
|
851
|
+
rawPath: "",
|
|
852
|
+
arg
|
|
853
|
+
};
|
|
854
|
+
if (!arg.evaluated) return;
|
|
855
|
+
if (typeof arg.value !== "string") {
|
|
856
|
+
error(ctx, arg.location, `@${decorator.name} expects a path (string), got ${describe(arg.value)}`, [`@${decorator.name}('/:id')`]);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
rawPath: arg.value,
|
|
861
|
+
arg
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/** `@Sse(path?, options?)` / `@Sse(options)`: the path and the options (`undefined` after an error). */
|
|
865
|
+
function sseArgs(ctx, decorator) {
|
|
866
|
+
const [first, second] = decorator.args;
|
|
867
|
+
let pathArg;
|
|
868
|
+
let options;
|
|
869
|
+
if (first !== void 0) {
|
|
870
|
+
if (!first.evaluated) return;
|
|
871
|
+
if (typeof first.value === "string") {
|
|
872
|
+
pathArg = first;
|
|
873
|
+
options = second;
|
|
874
|
+
} else if (first.value === void 0) options = second;
|
|
875
|
+
else if (isPlainObject(first.value)) {
|
|
876
|
+
if (second !== void 0) {
|
|
877
|
+
error(ctx, second.location, "@Sse(options) takes a single argument", ["Put the path first: @Sse('/events', { retry: 5000 })"]);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
options = first;
|
|
881
|
+
} else {
|
|
882
|
+
error(ctx, first.location, `@Sse expects a path (string) or options, got ${describe(first.value)}`, ["@Sse('/events'), @Sse('/events', { heartbeat: 15_000 }) or @Sse({ retry: 5000 })"]);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const sse = sseOptions(ctx, options);
|
|
887
|
+
if (sse === void 0) return;
|
|
888
|
+
return {
|
|
889
|
+
rawPath: typeof pathArg?.value === "string" ? pathArg.value : "",
|
|
890
|
+
arg: pathArg,
|
|
891
|
+
sse
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/** The static `{ heartbeat?, retry?, maxDuration? }` of `@Sse` (`undefined` after an error). */
|
|
895
|
+
function sseOptions(ctx, arg) {
|
|
896
|
+
if (arg === void 0) return {};
|
|
897
|
+
if (!arg.evaluated) return;
|
|
898
|
+
const shape = "{ heartbeat?: number | false, retry?: number, maxDuration?: number }";
|
|
899
|
+
if (arg.value === void 0) return {};
|
|
900
|
+
if (!isPlainObject(arg.value)) {
|
|
901
|
+
error(ctx, arg.location, `@Sse options must be an object ${shape}, got ${describe(arg.value)}`);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const result = {};
|
|
905
|
+
let ok = true;
|
|
906
|
+
const invalid = (message, hints = []) => {
|
|
907
|
+
ok = false;
|
|
908
|
+
error(ctx, arg.location, `@Sse: ${message}`, hints);
|
|
909
|
+
};
|
|
910
|
+
const integer = (value, min, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
|
|
911
|
+
for (const [key, value] of Object.entries(arg.value)) {
|
|
912
|
+
if (value === void 0) continue;
|
|
913
|
+
switch (key) {
|
|
914
|
+
case "heartbeat":
|
|
915
|
+
if (value === false) result.heartbeat = 0;
|
|
916
|
+
else if (integer(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) result.heartbeat = value;
|
|
917
|
+
else invalid(`"heartbeat" must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS}, or false, got ${describe(value)}`, ["Keep it below the idle timeouts of the proxies and of Bun.serve() (10 s): the default is 8 000"]);
|
|
918
|
+
break;
|
|
919
|
+
case "retry":
|
|
920
|
+
if (integer(value, 0)) result.retry = value;
|
|
921
|
+
else invalid(`"retry" must be an integer number of ms ≥ 0, got ${describe(value)}`);
|
|
922
|
+
break;
|
|
923
|
+
case "maxDuration":
|
|
924
|
+
if (integer(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) result.maxDuration = value;
|
|
925
|
+
else invalid(`"maxDuration" must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS} (about 24.8 days), got ${describe(value)}`);
|
|
926
|
+
break;
|
|
927
|
+
default: invalid(`unknown option "${key}"`, [`Options: ${shape}`]);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return ok ? result : void 0;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* `@Sse` handlers: the plugin writes the stream with status 200 and its own content type, so
|
|
934
|
+
* `@HttpCode`, `@Header('content-type')` and `@Res()` are errors; a body is a warning (EventSource
|
|
935
|
+
* sends `GET` without body); a primitive return type is an error.
|
|
936
|
+
*/
|
|
937
|
+
function checkSseHandler(ctx, method, params, where, bodyValidation) {
|
|
938
|
+
for (const decorator of method.decorators) if (decorator.identity === Ids.HttpCode) error(ctx, decorator.location, `@HttpCode has no effect on @Sse route ${where}: an event stream answers 200`, ["Throw an HttpException or return an HttpResponse from the handler for other statuses"]);
|
|
939
|
+
else if (decorator.identity === Ids.Header) {
|
|
940
|
+
const [name] = decorator.args;
|
|
941
|
+
if (name?.evaluated === true && typeof name.value === "string" && name.value.toLowerCase() === "content-type") error(ctx, decorator.location, `@Header('content-type') has no effect on @Sse route ${where}`, ["An event stream is always sent as text/event-stream; charset=utf-8"]);
|
|
942
|
+
}
|
|
943
|
+
for (const param of params) if (param.source === "response") error(ctx, param.location, `@Res() cannot be used on @Sse route ${where}: the plugin writes the event stream`, ["Return an AsyncIterable of SseEvent, or use a plain route to write the response yourself"]);
|
|
944
|
+
const bodyLocation = params.find((param) => param.source === "body" || param.upload !== void 0)?.location ?? bodyValidation?.decorator.location;
|
|
945
|
+
if (bodyLocation !== void 0) ctx.report({
|
|
946
|
+
code: Codes.InvalidDecorator,
|
|
947
|
+
severity: "warning",
|
|
948
|
+
message: `@Sse route ${where} reads a request body: EventSource sends GET requests without body`,
|
|
949
|
+
location: bodyLocation,
|
|
950
|
+
hints: ["For fetch() clients streaming a POST, use @Post() and return HttpResponse.sse(source)"]
|
|
951
|
+
});
|
|
952
|
+
const returned = method.returnType === void 0 ? void 0 : ctx.types.awaited(method.returnType);
|
|
953
|
+
if (returned !== void 0 && !method.generator) {
|
|
954
|
+
if ((returned.kind === "union" ? returned.types : [returned]).some((member) => member.kind === "literal" || member.kind === "primitive" && NOT_SSE_SOURCES.has(member.name))) error(ctx, method.location, `@Sse handler ${where} returns ${ctx.types.format(returned)}: return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (or an HttpResponse)`, ["async events(): Promise<AsyncIterable<SseEvent<T>>> { ...; return channel }"]);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/** `@BodyLimit(size)` of a class or method, in bytes. */
|
|
958
|
+
function bodyLimitOf(ctx, decorators) {
|
|
959
|
+
const [decorator, ...duplicates] = decorators.filter((candidate) => candidate.identity === Ids.BodyLimit);
|
|
960
|
+
for (const duplicate of duplicates) error(ctx, duplicate.location, "@BodyLimit is applied several times", ["Keep a single @BodyLimit(size)"]);
|
|
961
|
+
if (decorator === void 0) return;
|
|
962
|
+
const [arg] = decorator.args;
|
|
963
|
+
if (arg === void 0) {
|
|
964
|
+
error(ctx, decorator.location, "@BodyLimit requires a size", ["@BodyLimit('10mb') or @BodyLimit(10_485_760)"]);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!arg.evaluated) return;
|
|
968
|
+
const size = parseByteSize(arg.value);
|
|
969
|
+
if (size === void 0) error(ctx, arg.location, `@BodyLimit expects a size in bytes, got ${describe(arg.value)}`, ["A non-negative integer (10_485_760), a size with a unit ('10mb', '512kb'; kb = 1024 bytes) or Infinity"]);
|
|
970
|
+
return size;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Checks the upload parameters of a handler together (one parameter per field; a parameter without
|
|
974
|
+
* field takes every file and stands alone). Returns whether the handler reads uploaded files.
|
|
975
|
+
*/
|
|
976
|
+
function checkUploads(ctx, params, where) {
|
|
977
|
+
const uploads = params.filter((param) => param.upload !== void 0);
|
|
978
|
+
uploads.forEach((param, index) => {
|
|
979
|
+
const field = param.upload?.field;
|
|
980
|
+
const previous = uploads.slice(0, index).find((other) => field === void 0 || other.upload?.field === void 0 || other.upload.field === field);
|
|
981
|
+
if (previous === void 0) return;
|
|
982
|
+
ctx.report({
|
|
983
|
+
code: Codes.InvalidDecorator,
|
|
984
|
+
severity: "error",
|
|
985
|
+
message: field !== void 0 && previous.upload?.field === field ? `The field "${field}" of ${where} is read by several upload parameters` : `${where}: an upload parameter without field receives every file of the body and cannot be combined with other upload parameters`,
|
|
986
|
+
location: param.location,
|
|
987
|
+
hints: ["Give each @UploadedFile / @UploadedFiles parameter its own field"],
|
|
988
|
+
related: [{
|
|
989
|
+
message: "Other upload parameter",
|
|
990
|
+
location: previous.location
|
|
991
|
+
}]
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
return uploads.length > 0;
|
|
995
|
+
}
|
|
996
|
+
/** HTTP method decorators (and parameter decorators) on a method without route decorator. */
|
|
997
|
+
function reportNotARoute(ctx, method, where) {
|
|
998
|
+
const hint = [`Add a route decorator to ${where}: @Get(), @Post(), @Put(), @Patch(), @Delete()...`];
|
|
999
|
+
for (const decorator of method.decorators) if (decorator.owner === "http" && validTarget(decorator)) error(ctx, decorator.location, `@${decorator.name} has no effect: ${where} is not a route`, hint);
|
|
1000
|
+
else if (isRouteMetaDecorator(ctx, decorator)) ctx.report({
|
|
1001
|
+
code: Codes.InvalidDecorator,
|
|
1002
|
+
severity: "warning",
|
|
1003
|
+
message: `@${decorator.name} has no effect: ${where} is not a route`,
|
|
1004
|
+
location: decorator.location,
|
|
1005
|
+
hints: hint
|
|
1006
|
+
});
|
|
1007
|
+
for (const param of method.params) for (const decorator of unique(param.decorators)) if (decorator.owner === "http" && validTarget(decorator)) error(ctx, decorator.location, `@${decorator.name} has no effect: ${where} is not a route`, hint);
|
|
1008
|
+
}
|
|
1009
|
+
/** A route handler must be a public, non-static method with a plain name. */
|
|
1010
|
+
function checkHandler(ctx, method, where, decorator) {
|
|
1011
|
+
let problem;
|
|
1012
|
+
let hint = "Route handlers are public instance methods";
|
|
1013
|
+
if (method.static) problem = `Static method ${where} cannot be a route handler`;
|
|
1014
|
+
else if (method.privateName) {
|
|
1015
|
+
problem = `#private method ${where} cannot be a route handler`;
|
|
1016
|
+
hint = "Use a public method (the generated code calls it from outside the class)";
|
|
1017
|
+
} else if (method.visibility !== "public") {
|
|
1018
|
+
problem = `${method.visibility === "protected" ? "Protected" : "Private"} method ${where} cannot be a route handler`;
|
|
1019
|
+
hint = "Make the method public (the generated code calls it from outside the class)";
|
|
1020
|
+
} else if (method.kind !== "method") problem = `Accessor ${where} cannot be a route handler`;
|
|
1021
|
+
else if (method.computed) problem = `${where} cannot be a route handler: handlers need a plain name (no computed key)`;
|
|
1022
|
+
if (problem !== void 0) {
|
|
1023
|
+
error(ctx, decorator.location, problem, [hint]);
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
function httpCode(ctx, decorators) {
|
|
1029
|
+
const [decorator, ...duplicates] = decorators.filter((candidate) => candidate.identity === Ids.HttpCode);
|
|
1030
|
+
for (const duplicate of duplicates) error(ctx, duplicate.location, "@HttpCode is applied several times", ["Keep a single @HttpCode(status)"]);
|
|
1031
|
+
if (decorator === void 0) return;
|
|
1032
|
+
const [arg] = decorator.args;
|
|
1033
|
+
if (arg === void 0) {
|
|
1034
|
+
error(ctx, decorator.location, "@HttpCode requires a status", ["@HttpCode(202)"]);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (!arg.evaluated) return;
|
|
1038
|
+
const status = arg.value;
|
|
1039
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) {
|
|
1040
|
+
error(ctx, arg.location, `@HttpCode expects an integer status between 100 and 599, got ${describe(status)}`);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
return status;
|
|
1044
|
+
}
|
|
1045
|
+
function responseHeaders(ctx, decorators) {
|
|
1046
|
+
const headers = [];
|
|
1047
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1048
|
+
for (const decorator of decorators) {
|
|
1049
|
+
if (decorator.identity !== Ids.Header) continue;
|
|
1050
|
+
const [name, value] = decorator.args;
|
|
1051
|
+
if (name === void 0 || value === void 0) {
|
|
1052
|
+
error(ctx, decorator.location, "@Header requires a name and a value", ["@Header('cache-control', 'no-store')"]);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (!name.evaluated || !value.evaluated) continue;
|
|
1056
|
+
if (typeof name.value !== "string" || !TOKEN.test(name.value)) {
|
|
1057
|
+
error(ctx, name.location, `@Header: invalid header name ${describe(name.value)}`, ["Header names are tokens: letters, digits and ! # $ % & ' * + - . ^ _ ` | ~"]);
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (typeof value.value !== "string" || /[\r\n\0]/.test(value.value)) {
|
|
1061
|
+
error(ctx, value.location, `@Header: the value of "${name.value}" must be a string without line breaks`);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const lower = name.value.toLowerCase();
|
|
1065
|
+
const previous = seen.get(lower);
|
|
1066
|
+
if (previous) {
|
|
1067
|
+
ctx.report({
|
|
1068
|
+
code: Codes.InvalidDecorator,
|
|
1069
|
+
severity: "error",
|
|
1070
|
+
message: `@Header: "${lower}" is set several times`,
|
|
1071
|
+
location: decorator.location,
|
|
1072
|
+
hints: ["Set each response header once (or use ctx.appendHeader() in the handler)"],
|
|
1073
|
+
related: [{
|
|
1074
|
+
message: "First set here",
|
|
1075
|
+
location: previous
|
|
1076
|
+
}]
|
|
1077
|
+
});
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
seen.set(lower, decorator.location);
|
|
1081
|
+
headers.push([lower, value.value]);
|
|
1082
|
+
}
|
|
1083
|
+
return headers;
|
|
1084
|
+
}
|
|
1085
|
+
/** `@Validate(schema)` of a handler. */
|
|
1086
|
+
function validateSchema(ctx, decorators) {
|
|
1087
|
+
const [decorator, ...duplicates] = decorators.filter((candidate) => candidate.identity === Ids.Validate);
|
|
1088
|
+
for (const duplicate of duplicates) error(ctx, duplicate.location, "@Validate is applied several times", ["Keep a single @Validate(schema): compose the validation in the schema"]);
|
|
1089
|
+
if (decorator === void 0) return;
|
|
1090
|
+
const [arg] = decorator.args;
|
|
1091
|
+
if (arg === void 0) {
|
|
1092
|
+
error(ctx, decorator.location, "@Validate requires a schema", ["@Validate(CreateUser)"]);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const schema = schemaRef(ctx, arg, "@Validate");
|
|
1096
|
+
return schema ? {
|
|
1097
|
+
schema,
|
|
1098
|
+
decorator
|
|
1099
|
+
} : void 0;
|
|
1100
|
+
}
|
|
1101
|
+
/** Guard / interceptor references of a class or method, bound to provider tokens (in order). */
|
|
1102
|
+
function classRefs(ctx, cls, decorators, identity, role) {
|
|
1103
|
+
const refs = [];
|
|
1104
|
+
for (const decorator of decorators) {
|
|
1105
|
+
if (decorator.identity !== identity) continue;
|
|
1106
|
+
if (decorator.called && decorator.args.length === 0) error(ctx, decorator.location, `@${decorator.name} requires at least one ${role}`, [`@${decorator.name}(My${role === "guard" ? "Guard" : "Interceptor"})`]);
|
|
1107
|
+
for (const arg of decorator.args) {
|
|
1108
|
+
const ref = ctx.resolveClassRef(arg, {
|
|
1109
|
+
from: cls,
|
|
1110
|
+
role
|
|
1111
|
+
});
|
|
1112
|
+
if (ref) refs.push(ref);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return refs;
|
|
1116
|
+
}
|
|
1117
|
+
function analyzeParams(ctx, cls, method, where, bodyValidation) {
|
|
1118
|
+
const params = [];
|
|
1119
|
+
let complete = true;
|
|
1120
|
+
for (const param of method.params) {
|
|
1121
|
+
const [decorator, ...extra] = unique(param.decorators).filter((decorator) => decorator.owner === "http" && PARAM_SOURCES.has(decorator.identity ?? ""));
|
|
1122
|
+
const foreign = decorator === void 0 ? foreignParameterDecorator(param) : void 0;
|
|
1123
|
+
if (foreign !== void 0) {
|
|
1124
|
+
error(ctx, foreign.decorator.location, `@${foreign.decorator.name} on parameter '${param.name}' of ${where} is imported from '${foreign.module}': route parameters use ${foreign.name} from '${HTTP_PACKAGE}'`, [`Import it from '${HTTP_PACKAGE}': import { ${foreign.name} } from '${HTTP_PACKAGE}'`]);
|
|
1125
|
+
complete = false;
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (decorator === void 0) {
|
|
1129
|
+
error(ctx, param.location, `Parameter '${param.name}' of ${where} has no HTTP decorator`, ["Decorate it with @Param(), @Query(), @Body(), @Headers(), @Cookie(), @SignedCookie(), @UploadedFile(), @UploadedFiles(), @Ctx(), @Req() or @Res()"]);
|
|
1130
|
+
complete = false;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
for (const other of extra) error(ctx, other.location, `Parameter '${param.name}' of ${where} has several HTTP decorators`, ["Keep a single parameter decorator"]);
|
|
1134
|
+
if (param.rest) {
|
|
1135
|
+
error(ctx, decorator.location, `Rest parameter '${param.name}' of ${where} cannot be injected`);
|
|
1136
|
+
complete = false;
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const data = analyzeParam(ctx, cls, method, param, decorator, where, bodyValidation);
|
|
1140
|
+
if (data) params.push(data);
|
|
1141
|
+
else complete = false;
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
params,
|
|
1145
|
+
complete
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function analyzeParam(ctx, cls, method, param, decorator, where, bodyValidation) {
|
|
1149
|
+
const source = PARAM_SOURCES.get(decorator.identity ?? "");
|
|
1150
|
+
if (source === void 0) return;
|
|
1151
|
+
const declared = parameterType(ctx, cls, method, param);
|
|
1152
|
+
const defaultValue = staticDefault(param);
|
|
1153
|
+
const base = {
|
|
1154
|
+
index: param.index,
|
|
1155
|
+
name: param.name,
|
|
1156
|
+
source,
|
|
1157
|
+
type: void 0,
|
|
1158
|
+
...defaultValue === void 0 ? {} : { defaultValue: defaultValue.value },
|
|
1159
|
+
location: decorator.location
|
|
1160
|
+
};
|
|
1161
|
+
const type = declared.type === void 0 ? void 0 : ctx.types.format(declared.type);
|
|
1162
|
+
const plain = () => ({
|
|
1163
|
+
...base,
|
|
1164
|
+
key: void 0,
|
|
1165
|
+
optional: false,
|
|
1166
|
+
schema: void 0,
|
|
1167
|
+
coercion: void 0
|
|
1168
|
+
});
|
|
1169
|
+
switch (source) {
|
|
1170
|
+
case "request":
|
|
1171
|
+
case "context": return plain();
|
|
1172
|
+
case "response":
|
|
1173
|
+
ctx.report({
|
|
1174
|
+
code: Codes.InvalidDecorator,
|
|
1175
|
+
severity: "warning",
|
|
1176
|
+
message: `@Res() makes ${where} adapter-specific: the handler writes the native response itself`,
|
|
1177
|
+
location: decorator.location,
|
|
1178
|
+
hints: ["Prefer returning a value, an HttpResponse or a Web Response (portable across adapters)"]
|
|
1179
|
+
});
|
|
1180
|
+
return plain();
|
|
1181
|
+
case "body": {
|
|
1182
|
+
const [arg] = decorator.args;
|
|
1183
|
+
let schema;
|
|
1184
|
+
if (arg !== void 0) {
|
|
1185
|
+
schema = schemaRef(ctx, arg, "@Body");
|
|
1186
|
+
if (schema === void 0) return;
|
|
1187
|
+
}
|
|
1188
|
+
if (bodyValidation !== void 0) {
|
|
1189
|
+
if (schema !== void 0) {
|
|
1190
|
+
ctx.report({
|
|
1191
|
+
code: Codes.InvalidDecorator,
|
|
1192
|
+
severity: "error",
|
|
1193
|
+
message: `The body of ${where} is validated twice: @Validate(${bodyValidation.schema.text}) and @Body(${schema.text})`,
|
|
1194
|
+
location: bodyValidation.decorator.location,
|
|
1195
|
+
hints: ["Keep one of them: @Validate(S) on the method is the same as @Body(S) on the parameter"],
|
|
1196
|
+
related: [{
|
|
1197
|
+
message: "@Body schema",
|
|
1198
|
+
location: decorator.location
|
|
1199
|
+
}]
|
|
1200
|
+
});
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
schema = bodyValidation.schema;
|
|
1204
|
+
}
|
|
1205
|
+
if (schema === void 0) checkUnvalidated(ctx, declared.type, decorator, param, where, "body");
|
|
1206
|
+
return {
|
|
1207
|
+
...base,
|
|
1208
|
+
key: void 0,
|
|
1209
|
+
optional: declared.optional,
|
|
1210
|
+
schema,
|
|
1211
|
+
coercion: void 0
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
case "file":
|
|
1215
|
+
case "files": {
|
|
1216
|
+
const upload = uploadOf(ctx, decorator, param, declared, where, source === "files");
|
|
1217
|
+
return upload === void 0 ? void 0 : {
|
|
1218
|
+
...base,
|
|
1219
|
+
key: upload.field,
|
|
1220
|
+
optional: !upload.required,
|
|
1221
|
+
schema: void 0,
|
|
1222
|
+
coercion: void 0,
|
|
1223
|
+
upload
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
case "headers": {
|
|
1227
|
+
const [arg] = decorator.args;
|
|
1228
|
+
if (arg === void 0) return plain();
|
|
1229
|
+
if (!arg.evaluated) return;
|
|
1230
|
+
if (typeof arg.value !== "string" || !TOKEN.test(arg.value)) {
|
|
1231
|
+
error(ctx, arg.location, `@Headers: invalid header name ${describe(arg.value)}`, ["@Headers('x-api-key'), or @Headers() for every header"]);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const key = arg.value.toLowerCase();
|
|
1235
|
+
const coercion = coercionOf(ctx, declared, param, decorator, where, "headers", key);
|
|
1236
|
+
return coercion ? {
|
|
1237
|
+
...base,
|
|
1238
|
+
key,
|
|
1239
|
+
type,
|
|
1240
|
+
optional: declared.optional,
|
|
1241
|
+
schema: void 0,
|
|
1242
|
+
coercion
|
|
1243
|
+
} : void 0;
|
|
1244
|
+
}
|
|
1245
|
+
case "param":
|
|
1246
|
+
case "query": {
|
|
1247
|
+
const parsed = nameAndSchema(ctx, decorator);
|
|
1248
|
+
if (!parsed.ok) return;
|
|
1249
|
+
const { key, schema } = parsed;
|
|
1250
|
+
const optional = source === "query" && declared.optional;
|
|
1251
|
+
if (schema !== void 0) return {
|
|
1252
|
+
...base,
|
|
1253
|
+
key,
|
|
1254
|
+
optional,
|
|
1255
|
+
schema,
|
|
1256
|
+
coercion: void 0
|
|
1257
|
+
};
|
|
1258
|
+
if (key === void 0) {
|
|
1259
|
+
if (source === "query") checkUnvalidated(ctx, declared.type, decorator, param, where, "query");
|
|
1260
|
+
return {
|
|
1261
|
+
...base,
|
|
1262
|
+
key,
|
|
1263
|
+
optional: false,
|
|
1264
|
+
schema: void 0,
|
|
1265
|
+
coercion: void 0
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
const coercion = coercionOf(ctx, declared, param, decorator, where, source, key);
|
|
1269
|
+
return coercion ? {
|
|
1270
|
+
...base,
|
|
1271
|
+
key,
|
|
1272
|
+
type,
|
|
1273
|
+
optional,
|
|
1274
|
+
schema: void 0,
|
|
1275
|
+
coercion
|
|
1276
|
+
} : void 0;
|
|
1277
|
+
}
|
|
1278
|
+
case "cookie":
|
|
1279
|
+
case "signedCookie": return cookieParam(ctx, param, decorator, declared, where, base, type);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
/** `@Cookie(name?, schema?)` / `@SignedCookie(name, schema?)`. */
|
|
1283
|
+
function cookieParam(ctx, param, decorator, declared, where, base, type) {
|
|
1284
|
+
const parsed = nameAndSchema(ctx, decorator);
|
|
1285
|
+
if (!parsed.ok) return;
|
|
1286
|
+
const { key, schema } = parsed;
|
|
1287
|
+
const name = `@${decorator.name}`;
|
|
1288
|
+
if (key !== void 0 && !TOKEN.test(key)) {
|
|
1289
|
+
error(ctx, decorator.args[0]?.location ?? decorator.location, `${name}: invalid cookie name ${describe(key)}`, [`Cookie names are tokens (letters, digits and ! # $ % & ' * + - . ^ _ \` | ~): ${name}('session_id')`]);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (decorator.identity === Ids.SignedCookie && key === void 0) {
|
|
1293
|
+
error(ctx, decorator.location, "@SignedCookie requires a cookie name", ["@SignedCookie('session_id')"]);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const optional = declared.optional;
|
|
1297
|
+
if (schema !== void 0) return {
|
|
1298
|
+
...base,
|
|
1299
|
+
key,
|
|
1300
|
+
optional,
|
|
1301
|
+
schema,
|
|
1302
|
+
coercion: void 0
|
|
1303
|
+
};
|
|
1304
|
+
if (key === void 0) {
|
|
1305
|
+
checkUnvalidated(ctx, declared.type, decorator, param, where, "cookies");
|
|
1306
|
+
return {
|
|
1307
|
+
...base,
|
|
1308
|
+
key,
|
|
1309
|
+
optional: false,
|
|
1310
|
+
schema: void 0,
|
|
1311
|
+
coercion: void 0
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
const coercion = coercionOf(ctx, declared, param, decorator, where, "cookie", key);
|
|
1315
|
+
return coercion ? {
|
|
1316
|
+
...base,
|
|
1317
|
+
key,
|
|
1318
|
+
type,
|
|
1319
|
+
optional,
|
|
1320
|
+
schema: void 0,
|
|
1321
|
+
coercion
|
|
1322
|
+
} : void 0;
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* The statically evaluable default value of a parameter (`page = 1`), without references to
|
|
1326
|
+
* exported bindings. `ParameterInfo.initializer` is read defensively (compilers before RFC 0002 C4
|
|
1327
|
+
* do not provide it).
|
|
1328
|
+
*/
|
|
1329
|
+
function staticDefault(param) {
|
|
1330
|
+
const initializer = param.initializer;
|
|
1331
|
+
if (initializer === void 0 || !initializer.evaluated || initializer.ref !== void 0) return;
|
|
1332
|
+
const value = initializer.value;
|
|
1333
|
+
if (value === void 0 || hasRef(value)) return;
|
|
1334
|
+
return { value };
|
|
1335
|
+
}
|
|
1336
|
+
function hasRef(value) {
|
|
1337
|
+
if (isValueRef(value)) return true;
|
|
1338
|
+
if (Array.isArray(value)) return value.some(hasRef);
|
|
1339
|
+
if (typeof value === "object" && value !== null) return Object.values(value).some(hasRef);
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
/** `@UploadedFile(field?, options?)` / `@UploadedFiles(field?, options?)`: field, options and type. */
|
|
1343
|
+
function uploadOf(ctx, decorator, param, declared, where, multiple) {
|
|
1344
|
+
const name = `@${decorator.name}`;
|
|
1345
|
+
const [first, second] = decorator.args;
|
|
1346
|
+
let field;
|
|
1347
|
+
let options;
|
|
1348
|
+
if (first !== void 0) {
|
|
1349
|
+
if (!first.evaluated) return;
|
|
1350
|
+
if (typeof first.value === "string") {
|
|
1351
|
+
if (first.value === "") {
|
|
1352
|
+
error(ctx, first.location, `${name} expects a non-empty field name`, [`${name}('avatar')`]);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
field = first.value;
|
|
1356
|
+
options = second;
|
|
1357
|
+
} else if (first.value === void 0) options = second;
|
|
1358
|
+
else if (isPlainObject(first.value)) {
|
|
1359
|
+
if (second !== void 0) {
|
|
1360
|
+
error(ctx, second.location, `${name}(options) takes a single argument`, [`Put the field first: ${name}('field', { ... })`]);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
options = first;
|
|
1364
|
+
} else {
|
|
1365
|
+
error(ctx, first.location, `${name} expects a field name (string) or options, got ${describe(first.value)}`, [`${name}('avatar'), ${name}('avatar', { maxSize: '5mb' }) or ${name}({ maxSize: '5mb' })`]);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
const parsed = uploadOptions(ctx, name, options, multiple);
|
|
1370
|
+
if (parsed === void 0) return;
|
|
1371
|
+
const type = uploadType(ctx, declared.type, multiple);
|
|
1372
|
+
if (!type.ok) {
|
|
1373
|
+
error(ctx, param.typeLocation, `${name} parameter '${param.name}' of ${where} must be typed ${multiple ? "File[]" : "File"}, got ${type.got}`, [multiple ? `${param.name}: File[]` : `${param.name}: File (or ${param.name}?: File when optional)`]);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const required = parsed.required ?? !declared.optional;
|
|
1377
|
+
let cast = type.cast;
|
|
1378
|
+
if (!multiple && !required) {
|
|
1379
|
+
const acceptsMissing = declared.type === void 0 || param.optional || param.hasDefault || acceptsUndefined(declared.type) || isRaw(ctx.types.stripNullish(declared.type).type);
|
|
1380
|
+
if (!acceptsMissing && !declared.optional) {
|
|
1381
|
+
error(ctx, options?.location ?? decorator.location, `${name}: '${param.name}' of ${where} is optional ({ required: false }) but typed ${ctx.types.format(declared.type)}`, [`Type it ${param.name}?: File (undefined when no file is sent)`]);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
cast ||= !acceptsMissing;
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
field,
|
|
1388
|
+
multiple,
|
|
1389
|
+
required,
|
|
1390
|
+
maxSize: parsed.maxSize,
|
|
1391
|
+
maxFiles: parsed.maxFiles,
|
|
1392
|
+
mimeTypes: parsed.mimeTypes,
|
|
1393
|
+
cast
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
/** The options object of an upload decorator (`undefined` after an error). */
|
|
1397
|
+
function uploadOptions(ctx, name, arg, multiple) {
|
|
1398
|
+
const result = {
|
|
1399
|
+
required: void 0,
|
|
1400
|
+
maxSize: void 0,
|
|
1401
|
+
maxFiles: void 0,
|
|
1402
|
+
mimeTypes: void 0
|
|
1403
|
+
};
|
|
1404
|
+
if (arg === void 0) return result;
|
|
1405
|
+
if (!arg.evaluated) return;
|
|
1406
|
+
if (arg.value === void 0) return result;
|
|
1407
|
+
const shape = `{ ${(multiple ? UPLOAD_OPTIONS : UPLOAD_OPTIONS.filter((option) => option !== "maxFiles")).map((option) => `${option}?`).join(", ")} }`;
|
|
1408
|
+
if (!isPlainObject(arg.value)) {
|
|
1409
|
+
error(ctx, arg.location, `${name} options must be an object ${shape}, got ${describe(arg.value)}`);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
let ok = true;
|
|
1413
|
+
const invalid = (message, hints = []) => {
|
|
1414
|
+
ok = false;
|
|
1415
|
+
error(ctx, arg.location, `${name}: ${message}`, hints);
|
|
1416
|
+
};
|
|
1417
|
+
for (const [key, value] of Object.entries(arg.value)) {
|
|
1418
|
+
if (value === void 0) continue;
|
|
1419
|
+
switch (key) {
|
|
1420
|
+
case "required":
|
|
1421
|
+
if (typeof value === "boolean") result.required = value;
|
|
1422
|
+
else invalid(`"required" must be a boolean, got ${describe(value)}`);
|
|
1423
|
+
break;
|
|
1424
|
+
case "maxSize": {
|
|
1425
|
+
const size = parseByteSize(value);
|
|
1426
|
+
if (size === void 0) invalid(`"maxSize" must be a size in bytes, got ${describe(value)}`, ["A non-negative integer (5_242_880), a size with a unit ('5mb', '512kb'; kb = 1024 bytes) or Infinity"]);
|
|
1427
|
+
else result.maxSize = size;
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
case "maxFiles":
|
|
1431
|
+
if (!multiple) invalid("\"maxFiles\" only applies to @UploadedFiles (a single file is expected here)", ["Use @UploadedFiles() to receive several files"]);
|
|
1432
|
+
else if (value === Number.POSITIVE_INFINITY || Number.isSafeInteger(value) && value >= 1) result.maxFiles = value;
|
|
1433
|
+
else invalid(`"maxFiles" must be a positive integer, got ${describe(value)}`);
|
|
1434
|
+
break;
|
|
1435
|
+
case "mimeTypes": {
|
|
1436
|
+
const patterns = Array.isArray(value) ? value : void 0;
|
|
1437
|
+
const wrong = patterns?.filter((pattern) => typeof pattern !== "string" || !isMediaTypePattern(pattern)) ?? [];
|
|
1438
|
+
if (patterns === void 0 || patterns.length === 0) invalid(`"mimeTypes" must be a non-empty array of media types, got ${describe(value)}`, ["mimeTypes: ['image/png', 'image/*']"]);
|
|
1439
|
+
else if (wrong.length > 0) invalid(`invalid media type ${wrong.map(describe).join(", ")} in "mimeTypes"`, ["Use type/subtype, type/* or */* without parameters: 'image/png', 'image/*'"]);
|
|
1440
|
+
else result.mimeTypes = patterns;
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
default: invalid(`unknown option "${key}"`, [`Options: ${shape}`]);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return ok ? result : void 0;
|
|
1447
|
+
}
|
|
1448
|
+
/** Whether a parameter type can receive uploaded files (and whether the value must be cast). */
|
|
1449
|
+
function uploadType(ctx, type, multiple) {
|
|
1450
|
+
if (type === void 0) return {
|
|
1451
|
+
ok: true,
|
|
1452
|
+
cast: false
|
|
1453
|
+
};
|
|
1454
|
+
const { type: base } = ctx.types.stripNullish(type);
|
|
1455
|
+
if (isRaw(base)) return {
|
|
1456
|
+
ok: true,
|
|
1457
|
+
cast: false
|
|
1458
|
+
};
|
|
1459
|
+
let target = base;
|
|
1460
|
+
if (multiple) target = base.kind === "array" ? base.element : void 0;
|
|
1461
|
+
if (target !== void 0 && isRaw(target)) return {
|
|
1462
|
+
ok: true,
|
|
1463
|
+
cast: false
|
|
1464
|
+
};
|
|
1465
|
+
const cast = target?.kind === "reference" ? FILE_TYPES.get(target.identity) : void 0;
|
|
1466
|
+
return cast === void 0 ? {
|
|
1467
|
+
ok: false,
|
|
1468
|
+
got: ctx.types.format(type)
|
|
1469
|
+
} : {
|
|
1470
|
+
ok: true,
|
|
1471
|
+
cast
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
/** Name and/or schema of `@Param(name?, schema?)` / `@Query(name?, schema?)`. */
|
|
1475
|
+
function nameAndSchema(ctx, decorator) {
|
|
1476
|
+
const [first, second] = decorator.args;
|
|
1477
|
+
if (first === void 0) return {
|
|
1478
|
+
ok: true,
|
|
1479
|
+
key: void 0,
|
|
1480
|
+
schema: void 0
|
|
1481
|
+
};
|
|
1482
|
+
if (first.error !== void 0) return { ok: false };
|
|
1483
|
+
if (first.ref !== void 0) {
|
|
1484
|
+
if (second !== void 0) {
|
|
1485
|
+
error(ctx, second.location, `@${decorator.name}(schema) takes a single argument`, [`Put the name first: @${decorator.name}('name', Schema)`]);
|
|
1486
|
+
return { ok: false };
|
|
1487
|
+
}
|
|
1488
|
+
return {
|
|
1489
|
+
ok: true,
|
|
1490
|
+
key: void 0,
|
|
1491
|
+
schema: first.ref
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
if (typeof first.value !== "string" || first.value === "") {
|
|
1495
|
+
error(ctx, first.location, `@${decorator.name} expects a name (non-empty string) or a schema, got ${describe(first.value)}`, [`@${decorator.name}('id'), @${decorator.name}('id', Schema) or @${decorator.name}(Schema)`]);
|
|
1496
|
+
return { ok: false };
|
|
1497
|
+
}
|
|
1498
|
+
if (second === void 0) return {
|
|
1499
|
+
ok: true,
|
|
1500
|
+
key: first.value,
|
|
1501
|
+
schema: void 0
|
|
1502
|
+
};
|
|
1503
|
+
const schema = schemaRef(ctx, second, `@${decorator.name}`);
|
|
1504
|
+
return schema ? {
|
|
1505
|
+
ok: true,
|
|
1506
|
+
key: first.value,
|
|
1507
|
+
schema
|
|
1508
|
+
} : { ok: false };
|
|
1509
|
+
}
|
|
1510
|
+
/** A schema argument: a reference to an exported binding. */
|
|
1511
|
+
function schemaRef(ctx, arg, what) {
|
|
1512
|
+
if (arg.error !== void 0) return;
|
|
1513
|
+
if (arg.ref === void 0) {
|
|
1514
|
+
error(ctx, arg.location, `${what} expects a Standard Schema, got ${describe(arg.value)}`, ["Pass a schema declared with `export const Schema = z.object({ ... })` (Zod, Valibot, ArkType...)"]);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const symbol = arg.ref.symbol;
|
|
1518
|
+
if (symbol.kind === "declaration" && symbol.declaration.kind === "class" && !hasStandardSchema(ctx, arg.ref)) ctx.report({
|
|
1519
|
+
code: Codes.InvalidDecorator,
|
|
1520
|
+
severity: "warning",
|
|
1521
|
+
message: `${what}(${arg.ref.text}): ${arg.ref.name} is a class, not a Standard Schema; the validation fails at runtime`,
|
|
1522
|
+
location: arg.location,
|
|
1523
|
+
hints: ["Pass a Standard Schema object (Zod, Valibot, ArkType...): classes are not validators"]
|
|
1524
|
+
});
|
|
1525
|
+
return arg.ref;
|
|
1526
|
+
}
|
|
1527
|
+
function hasStandardSchema(ctx, ref) {
|
|
1528
|
+
const info = ref.identity === void 0 ? void 0 : ctx.classInfo(ref.identity);
|
|
1529
|
+
return info === void 0 || info.superClass !== void 0 || info.properties.some((property) => property.static && property.name === "~standard") || info.methods.some((member) => member.static && member.name === "~standard");
|
|
1530
|
+
}
|
|
1531
|
+
/** Coercion of a named parameter / query value / header without schema. */
|
|
1532
|
+
function coercionOf(ctx, declared, param, decorator, where, source, key) {
|
|
1533
|
+
const result = planCoercion(ctx, declared, param, source === "param");
|
|
1534
|
+
const label = `${source === "headers" ? "header" : source} "${key}"`;
|
|
1535
|
+
if (result.ok) {
|
|
1536
|
+
if (source === "query" && declared.type !== void 0 && isAny(declared.type)) unvalidated(ctx, decorator, `${label} of ${where} is typed any`);
|
|
1537
|
+
return result.coercion;
|
|
1538
|
+
}
|
|
1539
|
+
if (source === "query") {
|
|
1540
|
+
unvalidated(ctx, decorator, `${label} of ${where}: ${result.reason}`, result.hints);
|
|
1541
|
+
return {
|
|
1542
|
+
plan: { kind: "raw" },
|
|
1543
|
+
cast: false
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
ctx.report({
|
|
1547
|
+
code: Codes.InvalidRoute,
|
|
1548
|
+
severity: "error",
|
|
1549
|
+
message: `Invalid ${label} of ${where}: ${result.reason}`,
|
|
1550
|
+
location: param.typeLocation,
|
|
1551
|
+
hints: [...result.hints]
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
/** ZNIGHT017 for a non-primitive `@Body()` / `@Query()` without schema. */
|
|
1555
|
+
function checkUnvalidated(ctx, type, decorator, param, where, what) {
|
|
1556
|
+
if (type !== void 0 && acceptedWithoutSchema(ctx, type)) return;
|
|
1557
|
+
const described = type === void 0 ? "untyped" : `typed ${ctx.types.format(type)}`;
|
|
1558
|
+
unvalidated(ctx, decorator, `the ${what} of ${where} ('${param.name}', ${described}) has no schema`);
|
|
1559
|
+
}
|
|
1560
|
+
function unvalidated(ctx, decorator, detail, extraHints = []) {
|
|
1561
|
+
ctx.report({
|
|
1562
|
+
code: Codes.UnvalidatedInput,
|
|
1563
|
+
severity: ctx.options.strictValidation ? "error" : "warning",
|
|
1564
|
+
message: `Unvalidated input: ${detail}`,
|
|
1565
|
+
location: decorator.location,
|
|
1566
|
+
hints: [
|
|
1567
|
+
...extraHints.filter((hint) => !hint.startsWith("Or validate")),
|
|
1568
|
+
`Validate it with a Standard Schema: ${schemaHint(decorator.name)}`,
|
|
1569
|
+
"Or type the parameter as unknown and check it yourself"
|
|
1570
|
+
]
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
function schemaHint(decorator) {
|
|
1574
|
+
if (decorator === "Body") return "@Body(Schema) or @Validate(Schema)";
|
|
1575
|
+
if (decorator === "Cookie") return "@Cookie(Schema) for every cookie, or @Cookie('name', Schema)";
|
|
1576
|
+
return `@${decorator}('name', Schema)`;
|
|
1577
|
+
}
|
|
1578
|
+
function acceptedWithoutSchema(ctx, type) {
|
|
1579
|
+
const { type: base } = ctx.types.stripNullish(type);
|
|
1580
|
+
switch (base.kind) {
|
|
1581
|
+
case "primitive": return base.name !== "any" && base.name !== "object" && base.name !== "never" && base.name !== "symbol";
|
|
1582
|
+
case "literal": return true;
|
|
1583
|
+
case "reference":
|
|
1584
|
+
if (UNVALIDATED_REFERENCES.has(base.identity)) return true;
|
|
1585
|
+
return base.identity === "globalThis#Record" && base.typeArgs.length === 2 && isStringLike(base.typeArgs[1]);
|
|
1586
|
+
default: return false;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function isStringLike(type) {
|
|
1590
|
+
switch (type.kind) {
|
|
1591
|
+
case "primitive": return type.name === "string" || type.name === "undefined" || type.name === "unknown";
|
|
1592
|
+
case "array": return type.element.kind === "primitive" && type.element.name === "string";
|
|
1593
|
+
case "union": return type.types.every(isStringLike);
|
|
1594
|
+
default: return false;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
function isAny(type) {
|
|
1598
|
+
return isRaw(type) && type.kind === "primitive" && type.name === "any";
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Decorators of a parameter without duplicates (a parameter with a default value may list its
|
|
1602
|
+
* decorators twice).
|
|
1603
|
+
*/
|
|
1604
|
+
function unique(decorators) {
|
|
1605
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1606
|
+
return decorators.filter((decorator) => {
|
|
1607
|
+
if (seen.has(decorator.location.start)) return false;
|
|
1608
|
+
seen.add(decorator.location.start);
|
|
1609
|
+
return true;
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
/** `false` when the decorator is applied to a target it does not support (reported by the core). */
|
|
1613
|
+
function validTarget(decorator) {
|
|
1614
|
+
const identity = decorator.identity ?? "";
|
|
1615
|
+
if (ROUTE_METHODS.has(identity)) return decorator.target === "method";
|
|
1616
|
+
if (PARAM_SOURCES.has(identity)) return decorator.target === "parameter";
|
|
1617
|
+
switch (identity) {
|
|
1618
|
+
case Ids.Controller: return decorator.target === "class";
|
|
1619
|
+
case Ids.UseGuard:
|
|
1620
|
+
case Ids.UseInterceptor:
|
|
1621
|
+
case Ids.BodyLimit: return decorator.target === "class" || decorator.target === "method";
|
|
1622
|
+
default: return decorator.target === "method";
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
function isPlainObject(value) {
|
|
1626
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !isValueRef(value);
|
|
1627
|
+
}
|
|
1628
|
+
function describe(value) {
|
|
1629
|
+
if (value === null) return "null";
|
|
1630
|
+
if (Array.isArray(value)) return "an array";
|
|
1631
|
+
if (isValueRef(value)) return `a reference to '${value.text}'`;
|
|
1632
|
+
if (typeof value === "object") return "an object";
|
|
1633
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
1634
|
+
return typeof value === "bigint" ? `${value}n` : String(value);
|
|
1635
|
+
}
|
|
1636
|
+
function error(ctx, location, message, hints) {
|
|
1637
|
+
ctx.report({
|
|
1638
|
+
code: Codes.InvalidDecorator,
|
|
1639
|
+
severity: "error",
|
|
1640
|
+
message,
|
|
1641
|
+
location,
|
|
1642
|
+
...hints ? { hints } : {}
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
// src/compiler/generate.ts
|
|
1646
|
+
/** `uploads` helper by kind and presence (`@UploadedFile` / `@UploadedFiles`, required or not). */
|
|
1647
|
+
const UPLOAD_HELPERS = {
|
|
1648
|
+
file: {
|
|
1649
|
+
required: "file",
|
|
1650
|
+
optional: "optionalFile"
|
|
1651
|
+
},
|
|
1652
|
+
files: {
|
|
1653
|
+
required: "files",
|
|
1654
|
+
optional: "optionalFiles"
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
/** Generates `metadata.extensions.http` (`undefined` when the application has no controller). */
|
|
1658
|
+
function generateHttp(ctx) {
|
|
1659
|
+
if (ctx.stored.length === 0) return;
|
|
1660
|
+
const controllers = [...ctx.stored].sort((a, b) => compareStrings(a.token, b.token));
|
|
1661
|
+
const helpers = createHelpers(ctx);
|
|
1662
|
+
const entries = [["controllers", ctx.arr(controllers.map((controller) => controllerNode(ctx, helpers, controller)))]];
|
|
1663
|
+
const groups = groupsNode(ctx, controllers);
|
|
1664
|
+
if (groups !== void 0) entries.push(["groups", groups]);
|
|
1665
|
+
return ctx.obj(entries);
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* `groups`: the route decorator groups the runtime needs (required, holding several keys, or named
|
|
1669
|
+
* differently from their key), sorted, with sorted keys; `undefined` when there is none, so that
|
|
1670
|
+
* applications without groups keep a byte-identical output.
|
|
1671
|
+
*/
|
|
1672
|
+
function groupsNode(ctx, controllers) {
|
|
1673
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1674
|
+
const visit = (entries) => {
|
|
1675
|
+
for (const entry of entries) {
|
|
1676
|
+
const group = groups.get(entry.group);
|
|
1677
|
+
if (group === void 0) groups.set(entry.group, {
|
|
1678
|
+
keys: /* @__PURE__ */ new Set([entry.key]),
|
|
1679
|
+
required: entry.required
|
|
1680
|
+
});
|
|
1681
|
+
else {
|
|
1682
|
+
group.keys.add(entry.key);
|
|
1683
|
+
group.required ||= entry.required;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
1687
|
+
for (const controller of controllers) {
|
|
1688
|
+
visit(controller.meta);
|
|
1689
|
+
for (const route of controller.routes) visit(route.meta);
|
|
1690
|
+
}
|
|
1691
|
+
const needed = [...groups].filter(([name, group]) => group.required || group.keys.size > 1 || !group.keys.has(name)).sort(([a], [b]) => compareStrings(a, b));
|
|
1692
|
+
if (needed.length === 0) return;
|
|
1693
|
+
return ctx.obj(needed.map(([name, group]) => [name, ctx.obj([["keys", ctx.lit([...group.keys].sort(compareStrings))], ["required", group.required]])]));
|
|
1694
|
+
}
|
|
1695
|
+
function createHelpers(ctx) {
|
|
1696
|
+
let coerce;
|
|
1697
|
+
let validate;
|
|
1698
|
+
let uploads;
|
|
1699
|
+
let context;
|
|
1700
|
+
return {
|
|
1701
|
+
coerce: () => {
|
|
1702
|
+
coerce ??= ctx.importFrom(HTTP_PACKAGE, "coerce");
|
|
1703
|
+
return coerce;
|
|
1704
|
+
},
|
|
1705
|
+
validate: () => {
|
|
1706
|
+
validate ??= ctx.importFrom(HTTP_PACKAGE, "validate");
|
|
1707
|
+
return validate;
|
|
1708
|
+
},
|
|
1709
|
+
uploads: () => {
|
|
1710
|
+
uploads ??= ctx.importFrom(HTTP_PACKAGE, "uploads");
|
|
1711
|
+
return uploads;
|
|
1712
|
+
},
|
|
1713
|
+
context: () => {
|
|
1714
|
+
context ??= ctx.importFrom(HTTP_PACKAGE, "HttpContext", { type: true });
|
|
1715
|
+
return context;
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
function controllerNode(ctx, helpers, controller) {
|
|
1720
|
+
return ctx.obj([
|
|
1721
|
+
["token", controller.token],
|
|
1722
|
+
["basePath", controller.basePath],
|
|
1723
|
+
["guards", tokens(ctx, controller.guards)],
|
|
1724
|
+
["interceptors", tokens(ctx, controller.interceptors)],
|
|
1725
|
+
["meta", metaNode(ctx, controller.meta)],
|
|
1726
|
+
["routes", ctx.arr(controller.routes.map((route) => routeNode(ctx, helpers, controller, route)))]
|
|
1727
|
+
]);
|
|
1728
|
+
}
|
|
1729
|
+
function routeNode(ctx, helpers, controller, route) {
|
|
1730
|
+
const entries = [
|
|
1731
|
+
["method", route.method],
|
|
1732
|
+
["path", route.path],
|
|
1733
|
+
["fullPath", route.fullPath],
|
|
1734
|
+
["handler", route.handler]
|
|
1735
|
+
];
|
|
1736
|
+
if (route.status !== void 0) entries.push(["status", route.status]);
|
|
1737
|
+
if (route.headers.length > 0) entries.push(["headers", ctx.obj(route.headers.map(([name, value]) => [name, value]))]);
|
|
1738
|
+
entries.push(["meta", metaNode(ctx, route.meta)], ["guards", tokens(ctx, route.guards)], ["interceptors", tokens(ctx, route.interceptors)], ["params", ctx.arr(route.params.map((param) => paramNode(ctx, param)))]);
|
|
1739
|
+
if (route.bodyLimit !== void 0) entries.push(["bodyLimit", route.bodyLimit]);
|
|
1740
|
+
const uploads = route.params.flatMap((param) => param.upload === void 0 ? [] : [param.upload]);
|
|
1741
|
+
if (uploads.length > 0) entries.push(["uploads", ctx.arr(uploads.map((upload) => uploadNode(ctx, upload)))]);
|
|
1742
|
+
if (route.rawResponse) entries.push(["rawResponse", true]);
|
|
1743
|
+
if (route.sse !== void 0) entries.push(["sse", sseNode(ctx, route.sse)]);
|
|
1744
|
+
entries.push(["invoke", invokeNode(ctx, helpers, controller, route)]);
|
|
1745
|
+
return ctx.obj(entries);
|
|
1746
|
+
}
|
|
1747
|
+
/** `{ heartbeat?, retry?, maxDuration? }` (`{}` without options). */
|
|
1748
|
+
function sseNode(ctx, sse) {
|
|
1749
|
+
const entries = [];
|
|
1750
|
+
if (sse.heartbeat !== void 0) entries.push(["heartbeat", sse.heartbeat]);
|
|
1751
|
+
if (sse.retry !== void 0) entries.push(["retry", sse.retry]);
|
|
1752
|
+
if (sse.maxDuration !== void 0) entries.push(["maxDuration", sse.maxDuration]);
|
|
1753
|
+
return ctx.obj(entries);
|
|
1754
|
+
}
|
|
1755
|
+
function paramNode(ctx, param) {
|
|
1756
|
+
const entries = [["index", param.index], ["source", param.source]];
|
|
1757
|
+
if (param.key !== void 0) entries.push(["name", param.key]);
|
|
1758
|
+
if (param.type !== void 0) entries.push(["type", param.type]);
|
|
1759
|
+
if (param.optional) entries.push(["optional", true]);
|
|
1760
|
+
if (param.schema !== void 0) entries.push(["schema", true]);
|
|
1761
|
+
return ctx.obj(entries);
|
|
1762
|
+
}
|
|
1763
|
+
/** `{ field?, multiple?, required?, maxSize?, maxFiles?, mimeTypes? }` (absent values omitted). */
|
|
1764
|
+
function uploadNode(ctx, upload) {
|
|
1765
|
+
const entries = [];
|
|
1766
|
+
if (upload.field !== void 0) entries.push(["field", upload.field]);
|
|
1767
|
+
if (upload.multiple) entries.push(["multiple", true]);
|
|
1768
|
+
if (upload.required) entries.push(["required", true]);
|
|
1769
|
+
if (upload.maxSize !== void 0) entries.push(["maxSize", upload.maxSize]);
|
|
1770
|
+
if (upload.maxFiles !== void 0) entries.push(["maxFiles", upload.maxFiles]);
|
|
1771
|
+
if (upload.mimeTypes !== void 0) entries.push(["mimeTypes", ctx.lit(upload.mimeTypes)]);
|
|
1772
|
+
return ctx.obj(entries);
|
|
1773
|
+
}
|
|
1774
|
+
function metaNode(ctx, meta) {
|
|
1775
|
+
return ctx.obj(meta.map((entry) => [entry.key, ctx.lit(entry.value)]));
|
|
1776
|
+
}
|
|
1777
|
+
function tokens(ctx, refs) {
|
|
1778
|
+
return ctx.arr(refs.map((ref) => ctx.tokenOf(ref) ?? ref.token));
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* `(c: Controller, ctx: HttpContext) => c.handler(...args)`: `async` (awaiting every `validate()`)
|
|
1782
|
+
* when the route validates something with a schema, synchronous otherwise.
|
|
1783
|
+
*/
|
|
1784
|
+
function invokeNode(ctx, helpers, controller, route) {
|
|
1785
|
+
const async = route.bodySchema !== void 0 || route.params.some((param) => param.schema !== void 0);
|
|
1786
|
+
const params = [["c", ctx.importType(controller.identity)]];
|
|
1787
|
+
if (route.params.length > 0 || route.bodySchema !== void 0) params.push(["ctx", helpers.context()]);
|
|
1788
|
+
return ctx.arrow(params, (c, http) => {
|
|
1789
|
+
const args = [];
|
|
1790
|
+
for (const param of route.params) args[param.index] = argumentNode(ctx, helpers, http, param);
|
|
1791
|
+
const call = ctx.call(ctx.member(c, route.handler), Array.from(args, (arg) => arg ?? ctx.lit(void 0)));
|
|
1792
|
+
if (route.bodySchema === void 0) return call;
|
|
1793
|
+
const body = ctx.code`await ${ctx.call(ctx.member(http, "parseBody"))}`;
|
|
1794
|
+
const check = validation(ctx, helpers, route.bodySchema, body, "body");
|
|
1795
|
+
return ctx.code`{ ${check}; return ${call} }`;
|
|
1796
|
+
}, { async });
|
|
1797
|
+
}
|
|
1798
|
+
/** The expression passed to the handler for one parameter. */
|
|
1799
|
+
function argumentNode(ctx, helpers, http, param) {
|
|
1800
|
+
switch (param.source) {
|
|
1801
|
+
case "context": return http;
|
|
1802
|
+
case "request": return ctx.code`${ctx.member(http, "request")} as never`;
|
|
1803
|
+
case "response": return ctx.code`${ctx.member(http, "response")} as never`;
|
|
1804
|
+
case "body": {
|
|
1805
|
+
const body = ctx.member(http, "body");
|
|
1806
|
+
return param.schema === void 0 ? ctx.code`${body} as never` : validation(ctx, helpers, param.schema, body, "body");
|
|
1807
|
+
}
|
|
1808
|
+
case "file":
|
|
1809
|
+
case "files": {
|
|
1810
|
+
const upload = param.upload;
|
|
1811
|
+
const helper = UPLOAD_HELPERS[upload.multiple ? "files" : "file"][upload.required ? "required" : "optional"];
|
|
1812
|
+
const call = ctx.call(ctx.member(helpers.uploads(), helper), [http, ctx.lit(upload.field)]);
|
|
1813
|
+
return upload.cast ? ctx.code`${call} as never` : call;
|
|
1814
|
+
}
|
|
1815
|
+
case "headers":
|
|
1816
|
+
case "param":
|
|
1817
|
+
case "query": {
|
|
1818
|
+
const record = ctx.member(http, param.source === "param" ? "params" : param.source);
|
|
1819
|
+
if (param.key === void 0) {
|
|
1820
|
+
const location = param.source === "param" ? "params" : param.source;
|
|
1821
|
+
return param.schema === void 0 ? ctx.code`${record} as never` : validation(ctx, helpers, param.schema, record, location);
|
|
1822
|
+
}
|
|
1823
|
+
const value = ctx.code`${record}[${param.key}]`;
|
|
1824
|
+
const label = `${param.source === "headers" ? "header" : param.source} "${param.key}"`;
|
|
1825
|
+
if (param.schema !== void 0) return validation(ctx, helpers, param.schema, value, label);
|
|
1826
|
+
return coercion(ctx, helpers, param.coercion ?? {
|
|
1827
|
+
plan: { kind: "raw" },
|
|
1828
|
+
cast: false
|
|
1829
|
+
}, value, label);
|
|
1830
|
+
}
|
|
1831
|
+
case "cookie":
|
|
1832
|
+
case "signedCookie": {
|
|
1833
|
+
if (param.key === void 0) {
|
|
1834
|
+
const cookies = ctx.member(http, "cookies");
|
|
1835
|
+
return param.schema === void 0 ? ctx.code`${cookies} as never` : validation(ctx, helpers, param.schema, cookies, "cookies");
|
|
1836
|
+
}
|
|
1837
|
+
const value = param.source === "cookie" ? ctx.code`${ctx.member(http, "cookies")}[${param.key}]` : ctx.call(ctx.member(http, "signedCookie"), [param.key]);
|
|
1838
|
+
const label = `cookie "${param.key}"`;
|
|
1839
|
+
if (param.schema !== void 0) return validation(ctx, helpers, param.schema, value, label);
|
|
1840
|
+
return coercion(ctx, helpers, param.coercion ?? {
|
|
1841
|
+
plan: { kind: "raw" },
|
|
1842
|
+
cast: false
|
|
1843
|
+
}, value, label);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
/** `await validate(schema, value, location)`. */
|
|
1848
|
+
function validation(ctx, helpers, schema, value, location) {
|
|
1849
|
+
return ctx.code`await ${ctx.call(helpers.validate(), [
|
|
1850
|
+
ctx.ref(schema),
|
|
1851
|
+
value,
|
|
1852
|
+
location
|
|
1853
|
+
])}`;
|
|
1854
|
+
}
|
|
1855
|
+
/** `coerce.X(value, label, ...)`, cast with `as never` when the result does not have the declared type. */
|
|
1856
|
+
function coercion(ctx, helpers, coercion, value, label) {
|
|
1857
|
+
const node = coerceCall(ctx, helpers, coercion.plan, value, label);
|
|
1858
|
+
return coercion.cast ? ctx.code`${node} as never` : node;
|
|
1859
|
+
}
|
|
1860
|
+
function coerceCall(ctx, helpers, plan, value, label) {
|
|
1861
|
+
const helper = (name) => ctx.member(helpers.coerce(), name);
|
|
1862
|
+
switch (plan.kind) {
|
|
1863
|
+
case "raw": return ctx.code`${value} as never`;
|
|
1864
|
+
case "enum": return ctx.call(helper("enum"), [
|
|
1865
|
+
value,
|
|
1866
|
+
label,
|
|
1867
|
+
ctx.lit(plan.values)
|
|
1868
|
+
]);
|
|
1869
|
+
case "array":
|
|
1870
|
+
case "optional": return ctx.call(helper(plan.kind), [
|
|
1871
|
+
value,
|
|
1872
|
+
label,
|
|
1873
|
+
itemCoercer(ctx, helpers, plan.item)
|
|
1874
|
+
]);
|
|
1875
|
+
default: return ctx.call(helper(plan.kind), [value, label]);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
/** A `Coercer` value: `coerce.number`, or `(v, l) => coerce.enum(v, l, [...])`. */
|
|
1879
|
+
function itemCoercer(ctx, helpers, plan) {
|
|
1880
|
+
switch (plan.kind) {
|
|
1881
|
+
case "enum":
|
|
1882
|
+
case "array":
|
|
1883
|
+
case "optional": return ctx.arrow(["v", "l"], (v, l) => coerceCall(ctx, helpers, plan, v, l));
|
|
1884
|
+
case "raw": return ctx.arrow(["v"], (v) => v);
|
|
1885
|
+
default: return ctx.member(helpers.coerce(), plan.kind);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
// src/compiler/validate.ts
|
|
1889
|
+
/** Validates the routes of every controller. */
|
|
1890
|
+
function validateHttp(ctx) {
|
|
1891
|
+
const controllers = [...ctx.stored].sort((a, b) => compareStrings(a.token, b.token));
|
|
1892
|
+
const registered = /* @__PURE__ */ new Map();
|
|
1893
|
+
for (const controller of controllers) {
|
|
1894
|
+
const baseIssue = checkRoutePath(controller.rawBasePath);
|
|
1895
|
+
if (baseIssue !== void 0) {
|
|
1896
|
+
invalidRoute(ctx, controller.pathLocation, `Invalid base path ${JSON.stringify(controller.rawBasePath)} of ${controller.displayName}: ${baseIssue.message}`, GRAMMAR_HINTS);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
for (const route of controller.routes) {
|
|
1900
|
+
const where = `${controller.displayName}.${route.handler}`;
|
|
1901
|
+
const ownIssue = checkRoutePath(route.rawPath);
|
|
1902
|
+
const issue = ownIssue ?? checkRoutePath(route.fullPath);
|
|
1903
|
+
if (issue !== void 0) {
|
|
1904
|
+
const path = ownIssue === void 0 ? route.fullPath : route.rawPath;
|
|
1905
|
+
invalidRoute(ctx, route.pathLocation, `Invalid route path ${JSON.stringify(path)} of ${where}: ${issue.message}`, GRAMMAR_HINTS);
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1908
|
+
const parsed = parseRoutePath(route.fullPath);
|
|
1909
|
+
checkParams(ctx, route, parsed, where);
|
|
1910
|
+
const key = `${route.method} ${parsed.shape}`;
|
|
1911
|
+
const previous = registered.get(key);
|
|
1912
|
+
if (previous === void 0) {
|
|
1913
|
+
registered.set(key, {
|
|
1914
|
+
where,
|
|
1915
|
+
route
|
|
1916
|
+
});
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
ctx.report({
|
|
1920
|
+
code: Codes.InvalidRoute,
|
|
1921
|
+
severity: "error",
|
|
1922
|
+
message: `Duplicate route ${route.method} ${route.fullPath}: ${where} conflicts with ${previous.where} (${previous.route.method} ${previous.route.fullPath})`,
|
|
1923
|
+
location: route.location,
|
|
1924
|
+
hints: ["Two routes with the same method and the same path shape (parameter names aside) cannot coexist"],
|
|
1925
|
+
related: [{
|
|
1926
|
+
message: `${previous.where} is declared here`,
|
|
1927
|
+
location: previous.route.location
|
|
1928
|
+
}]
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
validateDecisions(ctx, controllers);
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Every route decorator definition, sorted by identity: the applied ones (first usage) and, when
|
|
1936
|
+
* the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are left to the
|
|
1937
|
+
* usages, which report them).
|
|
1938
|
+
*/
|
|
1939
|
+
function routeDecoratorDefinitions(ctx, controllers) {
|
|
1940
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
1941
|
+
const visit = (entries) => {
|
|
1942
|
+
for (const entry of entries) {
|
|
1943
|
+
const known = definitions.get(entry.identity);
|
|
1944
|
+
if (known === void 0) definitions.set(entry.identity, {
|
|
1945
|
+
identity: entry.identity,
|
|
1946
|
+
name: entry.decorator,
|
|
1947
|
+
key: entry.key,
|
|
1948
|
+
group: entry.group,
|
|
1949
|
+
required: entry.required,
|
|
1950
|
+
definition: entry.definition,
|
|
1951
|
+
flag: entry.value === true
|
|
1952
|
+
});
|
|
1953
|
+
else if (entry.value !== true) known.flag = false;
|
|
1954
|
+
}
|
|
1955
|
+
};
|
|
1956
|
+
for (const controller of controllers) {
|
|
1957
|
+
visit(controller.meta);
|
|
1958
|
+
for (const route of controller.routes) visit(route.meta);
|
|
1959
|
+
}
|
|
1960
|
+
const all = typeof ctx.definitionsOf === "function" ? ctx.definitionsOf(Ids.createRouteDecorator) : [];
|
|
1961
|
+
for (const found of all) {
|
|
1962
|
+
const [keyArg, optionsArg, ...extra] = found.args;
|
|
1963
|
+
const key = keyArg?.evaluated === true ? keyArg.value : void 0;
|
|
1964
|
+
if (typeof key !== "string" || key === "" || extra.length > 0 || optionsArg !== void 0 && !optionsArg.evaluated) continue;
|
|
1965
|
+
const parsed = parseRouteDecoratorOptions(key, optionsArg?.value);
|
|
1966
|
+
if ("error" in parsed) continue;
|
|
1967
|
+
const flag = found.typeArgs.length === 0;
|
|
1968
|
+
const known = definitions.get(found.identity);
|
|
1969
|
+
if (known !== void 0) {
|
|
1970
|
+
known.flag &&= flag;
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
definitions.set(found.identity, {
|
|
1974
|
+
identity: found.identity,
|
|
1975
|
+
name: found.name,
|
|
1976
|
+
key,
|
|
1977
|
+
...parsed,
|
|
1978
|
+
definition: found.location,
|
|
1979
|
+
flag
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
return [...definitions.values()].sort((a, b) => compareStrings(a.identity, b.identity));
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Route decorator groups (RFC 0001 §5.1): every definition of a key uses the same options and the
|
|
1986
|
+
* decorators of a group agree on `required` (ZNIGHT005, at the second definition); every route has
|
|
1987
|
+
* a decision of every required group, its own or its controller's (ZNIGHT023).
|
|
1988
|
+
*/
|
|
1989
|
+
function validateDecisions(ctx, controllers) {
|
|
1990
|
+
const definitions = routeDecoratorDefinitions(ctx, controllers);
|
|
1991
|
+
if (definitions.length === 0) return;
|
|
1992
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1993
|
+
const byGroup = /* @__PURE__ */ new Map();
|
|
1994
|
+
const members = /* @__PURE__ */ new Map();
|
|
1995
|
+
for (const definition of definitions) {
|
|
1996
|
+
const sameKey = byKey.get(definition.key);
|
|
1997
|
+
if (sameKey !== void 0 && (sameKey.group !== definition.group || sameKey.required !== definition.required)) {
|
|
1998
|
+
ctx.report({
|
|
1999
|
+
code: Codes.InvalidDecorator,
|
|
2000
|
+
severity: "error",
|
|
2001
|
+
message: `The route decorator ${definition.name} defines the key "${definition.key}" with ${describeOptions(definition)}, but ${sameKey.name} defines it with ${describeOptions(sameKey)}`,
|
|
2002
|
+
location: definition.definition,
|
|
2003
|
+
hints: ["Every definition of a key must use the same options: share one definition"],
|
|
2004
|
+
related: [{
|
|
2005
|
+
message: `${sameKey.name} is defined here`,
|
|
2006
|
+
location: sameKey.definition
|
|
2007
|
+
}]
|
|
2008
|
+
});
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
const sameGroup = byGroup.get(definition.group);
|
|
2012
|
+
if (sameGroup !== void 0 && sameGroup.required !== definition.required) {
|
|
2013
|
+
ctx.report({
|
|
2014
|
+
code: Codes.InvalidDecorator,
|
|
2015
|
+
severity: "error",
|
|
2016
|
+
message: `The "${definition.group}" group is ${definition.required ? "" : "not "}required for ${definition.name} but ${sameGroup.required ? "" : "not "}required for ${sameGroup.name}`,
|
|
2017
|
+
location: definition.definition,
|
|
2018
|
+
hints: [`All the decorators of the "${definition.group}" group must agree on required`],
|
|
2019
|
+
related: [{
|
|
2020
|
+
message: `${sameGroup.name} is defined here`,
|
|
2021
|
+
location: sameGroup.definition
|
|
2022
|
+
}]
|
|
2023
|
+
});
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
2026
|
+
byKey.set(definition.key, sameKey ?? definition);
|
|
2027
|
+
byGroup.set(definition.group, sameGroup ?? definition);
|
|
2028
|
+
const list = members.get(definition.group) ?? [];
|
|
2029
|
+
list.push(definition);
|
|
2030
|
+
members.set(definition.group, list);
|
|
2031
|
+
}
|
|
2032
|
+
const required = [...byGroup.values()].filter((definition) => definition.required);
|
|
2033
|
+
if (required.length === 0) return;
|
|
2034
|
+
for (const controller of controllers) {
|
|
2035
|
+
const controllerGroups = new Set(controller.meta.map((entry) => entry.group));
|
|
2036
|
+
for (const route of controller.routes) {
|
|
2037
|
+
const decided = /* @__PURE__ */ new Set([...controllerGroups, ...route.meta.map((entry) => entry.group)]);
|
|
2038
|
+
for (const group of required) {
|
|
2039
|
+
if (decided.has(group.group)) continue;
|
|
2040
|
+
const inOrder = [...members.get(group.group) ?? [group]].sort((a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start);
|
|
2041
|
+
const decorators = inOrder.map((definition) => `@${definition.name}(${definition.flag ? "" : "..."})`);
|
|
2042
|
+
const listed = decorators.length === 1 ? decorators[0] : `${decorators.slice(0, -1).join(", ")} or ${decorators.at(-1)}`;
|
|
2043
|
+
ctx.report({
|
|
2044
|
+
code: Codes.MissingRouteDecision,
|
|
2045
|
+
severity: "error",
|
|
2046
|
+
message: `Route ${route.method} ${route.fullPath} (${controller.displayName}.${route.handler}) has no "${group.group}" decision`,
|
|
2047
|
+
location: route.location,
|
|
2048
|
+
hints: [`Add ${listed} to the route or to ${controller.displayName}`],
|
|
2049
|
+
related: [{
|
|
2050
|
+
message: `The "${group.group}" group is required`,
|
|
2051
|
+
location: (inOrder[0] ?? group).definition
|
|
2052
|
+
}, {
|
|
2053
|
+
message: `${controller.displayName} is declared here`,
|
|
2054
|
+
location: controller.location
|
|
2055
|
+
}]
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
function describeOptions(entry) {
|
|
2062
|
+
const group = entry.group === entry.key ? "no group" : `group "${entry.group}"`;
|
|
2063
|
+
return entry.required ? `${group}, required` : group;
|
|
2064
|
+
}
|
|
2065
|
+
const GRAMMAR_HINTS = ["Paths are made of static segments (letters, digits and - . _ ~ ! $ & ' + , ; = @), :name parameters and a trailing * wildcard"];
|
|
2066
|
+
/** `:param` of the path ↔ `@Param('name')` parameters of the handler. */
|
|
2067
|
+
function checkParams(ctx, route, path, where) {
|
|
2068
|
+
const declared = new Set(path.params);
|
|
2069
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
2070
|
+
let everything = false;
|
|
2071
|
+
for (const param of route.params) {
|
|
2072
|
+
if (param.source === "context" || param.source === "request") {
|
|
2073
|
+
everything = true;
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
if (param.source !== "param") continue;
|
|
2077
|
+
if (param.key === void 0) {
|
|
2078
|
+
everything = true;
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
if (declared.has(param.key)) {
|
|
2082
|
+
consumed.add(param.key);
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
const available = path.params.filter((name) => name !== "*").map((name) => `:${name}`);
|
|
2086
|
+
invalidRoute(ctx, param.location, `@Param('${param.key}') of ${where}: the route ${route.method} ${route.fullPath} has no ${param.key === "*" ? "wildcard" : `":${param.key}" parameter`}`, [available.length > 0 ? `Parameters of the route: ${available.join(", ")}` : `Add ":${param.key}" to the route path`]);
|
|
2087
|
+
}
|
|
2088
|
+
if (everything || !route.complete) return;
|
|
2089
|
+
for (const name of path.params) if (name !== "*" && !consumed.has(name)) invalidRoute(ctx, route.pathLocation, `Route ${route.method} ${route.fullPath} declares ":${name}" but ${where} has no @Param('${name}') parameter`, [`Add a parameter: @Param('${name}') ${name}: string`]);
|
|
2090
|
+
}
|
|
2091
|
+
function invalidRoute(ctx, location, message, hints) {
|
|
2092
|
+
ctx.report({
|
|
2093
|
+
code: Codes.InvalidRoute,
|
|
2094
|
+
severity: "error",
|
|
2095
|
+
message,
|
|
2096
|
+
location,
|
|
2097
|
+
hints
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
// src/compiler/index.ts
|
|
2101
|
+
const ROUTE = {
|
|
2102
|
+
targets: ["method"],
|
|
2103
|
+
params: ["static"]
|
|
2104
|
+
};
|
|
2105
|
+
const NAME_OR_SCHEMA = {
|
|
2106
|
+
targets: ["parameter"],
|
|
2107
|
+
params: ["value", "value"]
|
|
2108
|
+
};
|
|
2109
|
+
const NO_ARGUMENT = {
|
|
2110
|
+
targets: ["parameter"],
|
|
2111
|
+
params: []
|
|
2112
|
+
};
|
|
2113
|
+
const CLASS_REFS = {
|
|
2114
|
+
targets: ["class", "method"],
|
|
2115
|
+
params: [],
|
|
2116
|
+
rest: "value"
|
|
2117
|
+
};
|
|
2118
|
+
const UPLOAD = {
|
|
2119
|
+
targets: ["parameter"],
|
|
2120
|
+
params: ["static", "static"]
|
|
2121
|
+
};
|
|
2122
|
+
/** Decorators owned by the extension, by canonical identity (static / value arguments, §4.6). */
|
|
2123
|
+
const decorators = Object.freeze({
|
|
2124
|
+
[Ids.Controller]: {
|
|
2125
|
+
targets: ["class"],
|
|
2126
|
+
params: ["static"]
|
|
2127
|
+
},
|
|
2128
|
+
...Object.fromEntries([...ROUTE_METHODS.keys()].map((identity) => [identity, ROUTE])),
|
|
2129
|
+
[Ids.Param]: NAME_OR_SCHEMA,
|
|
2130
|
+
[Ids.Query]: NAME_OR_SCHEMA,
|
|
2131
|
+
[Ids.Body]: {
|
|
2132
|
+
targets: ["parameter"],
|
|
2133
|
+
params: ["value"]
|
|
2134
|
+
},
|
|
2135
|
+
[Ids.Headers]: {
|
|
2136
|
+
targets: ["parameter"],
|
|
2137
|
+
params: ["static"]
|
|
2138
|
+
},
|
|
2139
|
+
[Ids.Req]: NO_ARGUMENT,
|
|
2140
|
+
[Ids.Res]: NO_ARGUMENT,
|
|
2141
|
+
[Ids.Ctx]: NO_ARGUMENT,
|
|
2142
|
+
[Ids.HttpCode]: {
|
|
2143
|
+
targets: ["method"],
|
|
2144
|
+
params: ["static"]
|
|
2145
|
+
},
|
|
2146
|
+
[Ids.Header]: {
|
|
2147
|
+
targets: ["method"],
|
|
2148
|
+
params: ["static", "static"]
|
|
2149
|
+
},
|
|
2150
|
+
[Ids.UseGuard]: CLASS_REFS,
|
|
2151
|
+
[Ids.UseInterceptor]: CLASS_REFS,
|
|
2152
|
+
[Ids.Validate]: {
|
|
2153
|
+
targets: ["method"],
|
|
2154
|
+
params: ["value"]
|
|
2155
|
+
},
|
|
2156
|
+
[Ids.UploadedFile]: UPLOAD,
|
|
2157
|
+
[Ids.UploadedFiles]: UPLOAD,
|
|
2158
|
+
[Ids.BodyLimit]: {
|
|
2159
|
+
targets: ["class", "method"],
|
|
2160
|
+
params: ["static"]
|
|
2161
|
+
},
|
|
2162
|
+
[Ids.Cookie]: NAME_OR_SCHEMA,
|
|
2163
|
+
[Ids.SignedCookie]: NAME_OR_SCHEMA,
|
|
2164
|
+
[Ids.Sse]: {
|
|
2165
|
+
targets: ["method"],
|
|
2166
|
+
params: ["static", "static"]
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
/** The `@znight/http` compiler extension. */
|
|
2170
|
+
const http = Object.freeze({
|
|
2171
|
+
apiVersion: 1,
|
|
2172
|
+
name: EXTENSION_NAME,
|
|
2173
|
+
decorators,
|
|
2174
|
+
analyzeClass: analyzeHttpClass,
|
|
2175
|
+
validate: validateHttp,
|
|
2176
|
+
generate: generateHttp
|
|
2177
|
+
});
|
|
2178
|
+
export { http as default };
|
|
2179
|
+
|
|
2180
|
+
//# sourceMappingURL=compiler.mjs.map
|